From 9ba772b18f392bb3fbbe0df47e307948694ae083 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 30 May 2025 10:29:28 +0200 Subject: [PATCH 001/209] [web] handle 3DS challenges for Stripe (#25918) * handle 3DS challenges on the subscription dashboard * add `/user/subscription/sync` endpoint * upgrade `stripe-js` & rm `react-stripe-js` * group related unit tests together * add modules `SubscriptionController` unit tests and convert to async/await * add `StripeClient` unit tests for 3DS failure GitOrigin-RevId: 9da4758703f6ef4ec08248b328abddbbdd8e44ad --- package-lock.json | 35 ++++++------------- .../app/src/Features/Subscription/Errors.js | 7 ++++ .../Subscription/SubscriptionController.js | 11 +++++- .../views/subscriptions/dashboard-react.pug | 1 + .../modals/confirm-change-plan-modal.tsx | 15 ++++++-- .../preview-subscription-change/root.tsx | 32 +++++++++++------ .../util/handle-stripe-payment-action.ts | 28 +++++++++++++++ services/web/frontend/js/utils/meta.ts | 1 + services/web/package.json | 3 +- 9 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts diff --git a/package-lock.json b/package-lock.json index 73b722b1f5..ce941a1670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11575,29 +11575,6 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, - "node_modules/@stripe/react-stripe-js": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.5.0.tgz", - "integrity": "sha512-oo5J2SNbuAUjE9XmQv/SOD7vgZCa1Y9OcZyRAfvQPkyrDrru35sg5c64ANdHEmOWUibism3+25rKdARSw3HOfA==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@stripe/stripe-js": ">=1.44.1 <7.0.0", - "react": ">=16.8.0 <20.0.0", - "react-dom": ">=16.8.0 <20.0.0" - } - }, - "node_modules/@stripe/stripe-js": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz", - "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==", - "license": "MIT", - "engines": { - "node": ">=12.16" - } - }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -44687,8 +44664,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", - "@stripe/react-stripe-js": "^3.1.1", - "@stripe/stripe-js": "^5.6.0", + "@stripe/stripe-js": "^7.3.0", "@xmldom/xmldom": "^0.7.13", "accepts": "^1.3.7", "ajv": "^8.12.0", @@ -45175,6 +45151,15 @@ "lodash": "^4.17.15" } }, + "services/web/node_modules/@stripe/stripe-js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.3.0.tgz", + "integrity": "sha512-xnCyFIEI5SQnQrKkCxVj7nS5fWTZap+zuIGzmmxLMdlmgahFJaihK4zogqE8YyKKTLtrp/EldkEijSgtXsRVDg==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "services/web/node_modules/@transloadit/prettier-bytes": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index cbcd0014f7..9ebb08c6db 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -26,10 +26,17 @@ class SubtotalLimitExceededError extends OError {} class HasPastDueInvoiceError extends OError {} +class PaymentActionRequiredError extends OError { + constructor(info) { + super('Payment action required', info) + } +} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, AddOnNotPresentError, + PaymentActionRequiredError, MissingBillingInfoError, ManuallyCollectedError, PendingChangeError, diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 7aa345e7a8..a38b41f628 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -15,7 +15,11 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager') const RecurlyEventHandler = require('./RecurlyEventHandler') const { expressify } = require('@overleaf/promise-utils') const OError = require('@overleaf/o-error') -const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') +const { + DuplicateAddOnError, + AddOnNotPresentError, + PaymentActionRequiredError, +} = require('./Errors') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') @@ -425,6 +429,11 @@ async function purchaseAddon(req, res, next) { 'Your subscription already includes this add-on', { addon: addOnCode } ) + } else if (err instanceof PaymentActionRequiredError) { + return res.status(402).json({ + message: 'Payment action required', + clientSecret: err.info.clientSecret, + }) } else { if (err instanceof Error) { OError.tag(err, 'something went wrong purchasing add-ons', { diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index d6a1bff49c..8cc5ec1976 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -27,6 +27,7 @@ block append meta meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) + meta(name="ol-stripeApiKey" content=settings.apis.stripe.publishableKey) meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx index 08cbf1743f..a964009dcc 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx @@ -1,7 +1,10 @@ import { useState } from 'react' import { useTranslation, Trans } from 'react-i18next' import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids' -import { postJSON } from '../../../../../../../../infrastructure/fetch-json' +import { + postJSON, + FetchError, +} from '../../../../../../../../infrastructure/fetch-json' import getMeta from '../../../../../../../../utils/meta' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url' @@ -14,6 +17,7 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' import OLButton from '@/features/ui/components/ol/ol-button' import OLNotification from '@/features/ui/components/ol/ol-notification' +import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action' export function ConfirmChangePlanModal() { const modalId: SubscriptionDashModalIds = 'change-to-plan' @@ -37,8 +41,13 @@ export function ConfirmChangePlanModal() { }) location.reload() } catch (e) { - setError(true) - setInflight(false) + const { handled } = await handleStripePaymentAction(e as FetchError) + if (handled) { + location.reload() + } else { + setError(true) + setInflight(false) + } } } 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 367a5e35a9..112d15d7e3 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 @@ -11,7 +11,7 @@ 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' -import { postJSON } from '@/infrastructure/fetch-json' +import { FetchError, postJSON } from '@/infrastructure/fetch-json' import Notification from '@/shared/components/notification' import OLCard from '@/features/ui/components/ol/ol-card' import OLRow from '@/features/ui/components/ol/ol-row' @@ -21,6 +21,7 @@ import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription import * as eventTracking from '@/infrastructure/event-tracking' import sparkleText from '@/shared/svgs/ai-sparkle-text.svg' import { useFeatureFlag } from '@/shared/context/split-test-context' +import handleStripePaymentAction from '../../util/handle-stripe-payment-action' function PreviewSubscriptionChange() { const preview = getMeta( @@ -279,16 +280,25 @@ function PreviewSubscriptionChange() { } async function payNow(preview: SubscriptionChangePreview) { - if (preview.change.type === 'add-on-purchase') { - await postJSON(`/user/subscription/addon/${preview.change.addOn.code}/add`) - } else if (preview.change.type === 'premium-subscription') { - await postJSON(subscriptionUpdateUrl, { - body: { plan_code: preview.change.plan.code }, - }) - } else { - throw new Error( - `Unknown subscription change preview type: ${preview.change}` - ) + try { + if (preview.change.type === 'add-on-purchase') { + await postJSON( + `/user/subscription/addon/${preview.change.addOn.code}/add` + ) + } else if (preview.change.type === 'premium-subscription') { + await postJSON(subscriptionUpdateUrl, { + body: { plan_code: preview.change.plan.code }, + }) + } else { + throw new Error( + `Unknown subscription change preview type: ${preview.change}` + ) + } + } catch (e) { + const { handled } = await handleStripePaymentAction(e as FetchError) + if (!handled) { + throw e + } } } diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts new file mode 100644 index 0000000000..fd29674893 --- /dev/null +++ b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts @@ -0,0 +1,28 @@ +import { FetchError, postJSON } from '@/infrastructure/fetch-json' +import getMeta from '../../../utils/meta' +import { loadStripe } from '@stripe/stripe-js/pure' + +export default async function handleStripePaymentAction( + error: FetchError +): Promise<{ handled: boolean }> { + const clientSecret = error?.data?.clientSecret + + if (clientSecret) { + const stripePublicKey = getMeta('ol-stripeApiKey') + const stripe = await loadStripe(stripePublicKey) + if (stripe) { + const manualConfirmationFlow = + await stripe.confirmCardPayment(clientSecret) + if (!manualConfirmationFlow.error) { + try { + await postJSON(`/user/subscription/sync`) + } catch (error) { + // if the sync fails, there may be stale data until the webhook is + // processed but we can't do any special handling for that in here + } + return { handled: true } + } + } + } + return { handled: false } +} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 9461635625..2a396c805b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -224,6 +224,7 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string + 'ol-stripeApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/package.json b/services/web/package.json index 609d24c0a3..cc286b9225 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -89,8 +89,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", - "@stripe/stripe-js": "^5.6.0", - "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^7.3.0", "@xmldom/xmldom": "^0.7.13", "accepts": "^1.3.7", "ajv": "^8.12.0", From fe64856be7d984d11dad0bfeac9cedad15e074b9 Mon Sep 17 00:00:00 2001 From: Christopher Hoskin Date: Fri, 30 May 2025 09:54:17 +0100 Subject: [PATCH 002/209] Merge pull request #26021 from overleaf/csh-issue-25976-dev-env-ci Upgrade to Redis 7.4 in dev and CI GitOrigin-RevId: 068e54899bf50a247fedd0243d66f1545bc7cf01 --- services/document-updater/docker-compose.ci.yml | 2 +- services/document-updater/docker-compose.yml | 2 +- services/history-v1/docker-compose.ci.yml | 2 +- services/history-v1/docker-compose.yml | 2 +- services/project-history/docker-compose.ci.yml | 2 +- services/project-history/docker-compose.yml | 2 +- services/real-time/docker-compose.ci.yml | 2 +- services/real-time/docker-compose.yml | 2 +- services/web/docker-compose.ci.yml | 2 +- services/web/docker-compose.yml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index 2fe97bd9b3..8236c51af9 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -45,7 +45,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index 8a94d1a24c..a7842bc0fd 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -48,7 +48,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index 0dfe8b99d3..69b218221d 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -66,7 +66,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index b87d859e1e..760f4f01e3 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -74,7 +74,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index 2fe97bd9b3..8236c51af9 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -45,7 +45,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index 68360baf44..2659916373 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -48,7 +48,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml index 9011627c06..a5a2292e72 100644 --- a/services/real-time/docker-compose.ci.yml +++ b/services/real-time/docker-compose.ci.yml @@ -43,7 +43,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index 9333271dcf..f1041164bc 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -46,7 +46,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 5cffe19810..6bb6cc768c 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -86,7 +86,7 @@ services: user: root redis: - image: redis + image: redis:7.4.3 mongo: image: mongo:7.0.20 diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 5314e94ed3..27c8cbbe8b 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -84,7 +84,7 @@ services: - "cypress:run-ct" redis: - image: redis + image: redis:7.4.3 mongo: image: mongo:7.0.20 From c6f42291479a108676eb269af7d23fd4614877cf Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Fri, 30 May 2025 07:34:24 -0400 Subject: [PATCH 003/209] Merge pull request #25952 from overleaf/em-split-editor-facade Split EditorFacade functionality for history OT (2nd attempt) GitOrigin-RevId: 2bc6d6c54a9f336fd4a69f0eb548dd06b9f06f5f --- .../features/ide-react/editor/share-js-doc.ts | 2 +- .../source-editor/extensions/realtime.ts | 73 ++++++++++++++----- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 96e866afec..a773684dcb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -365,7 +365,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) }) } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 36d9956a76..9118e4f151 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -5,6 +5,7 @@ import RangesTracker from '@overleaf/ranges-tracker' import { ShareDoc } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { OTType } from '@/features/ide-react/editor/share-js-doc' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -76,15 +77,22 @@ export const realtime = ( return Prec.highest([realtimePlugin, ensureRealtimePlugin]) } +type OTAdapter = { + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ): void + attachShareJs(): void +} + export class EditorFacade extends EventEmitter { - public shareDoc: ShareDoc | null + private otAdapter: OTAdapter | null public events: EventEmitter - private maxDocLength?: number constructor(public view: EditorView) { super() this.view = view - this.shareDoc = null + this.otAdapter = null this.events = new EventEmitter() } @@ -118,23 +126,56 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + this.otAdapter = + type === 'history-ot' + ? new HistoryOTAdapter(this, shareDoc, maxDocLength) + : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) + this.otAdapter.attachShareJs() + } + + detachShareJs() { + this.otAdapter = null + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + if (this.otAdapter == null) { + throw new Error('Trying to process updates with no otAdapter') + } + + this.otAdapter.handleUpdateFromCM(transactions, ranges) + } +} + +class ShareLatexOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: ShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + // Connect to ShareJS, passing changes to the CodeMirror view // as new transactions. // This is a broad immitation of helper functions supplied in // the sharejs library. (See vendor/libs/sharejs, in particular // the 'attach_ace' helper) - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { - this.shareDoc = shareDoc - this.maxDocLength = maxDocLength - + attachShareJs() { + const shareDoc = this.shareDoc const check = () => { // run in a timeout so it checks the editor content once this update has been applied window.setTimeout(() => { - const editorText = this.getValue() + const editorText = this.editor.getValue() const otText = shareDoc.getText() if (editorText !== otText) { - shareDoc.emit('error', 'Text does not match in CodeMirror 6') + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') debugConsole.error('Text does not match!') debugConsole.error('editor: ' + editorText) debugConsole.error('ot: ' + otText) @@ -143,12 +184,12 @@ export class EditorFacade extends EventEmitter { } const onInsert = (pos: number, text: string) => { - this.cmInsert(pos, text, 'remote') + this.editor.cmInsert(pos, text, 'remote') check() } const onDelete = (pos: number, text: string) => { - this.cmDelete(pos, text, 'remote') + this.editor.cmDelete(pos, text, 'remote') check() } @@ -161,7 +202,7 @@ export class EditorFacade extends EventEmitter { shareDoc.removeListener('insert', onInsert) shareDoc.removeListener('delete', onDelete) delete shareDoc.detach_cm6 - this.shareDoc = null + this.editor.detachShareJs() } } @@ -175,10 +216,6 @@ export class EditorFacade extends EventEmitter { const trackedDeletesLength = ranges != null ? ranges.getTrackedDeletesLength() : 0 - if (!shareDoc) { - throw new Error('Trying to process updates with no shareDoc') - } - for (const transaction of transactions) { if (transaction.docChanged) { const origin = chooseOrigin(transaction) @@ -234,7 +271,7 @@ export class EditorFacade extends EventEmitter { removed, } - this.emit('change', this, changeDescription) + this.editor.emit('change', this.editor, changeDescription) } ) } @@ -242,6 +279,8 @@ export class EditorFacade extends EventEmitter { } } +class HistoryOTAdapter extends ShareLatexOTAdapter {} + export const trackChangesAnnotation = Annotation.define() const chooseOrigin = (transaction: Transaction) => { From 26a77e739de0c306f0c012dccf964850e5490434 Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Fri, 30 May 2025 12:40:07 +0100 Subject: [PATCH 004/209] Merge pull request #25852 from overleaf/ls-sync-stripe-subscription-logic Replicate syncing logic for Stripe subscription GitOrigin-RevId: 9422a3e193160409eddd4c5f2c80e8578bd88559 --- .../Subscription/SubscriptionUpdater.js | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index b0e24ce5ad..98bd98b30e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -318,38 +318,7 @@ async function updateSubscriptionFromRecurly( requesterData ) { if (recurlySubscription.state === 'expired') { - const hasManagedUsersFeature = - Features.hasFeature('saas') && subscription?.managedUsersEnabled - - // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to - // make sure that the group continues as-is and no destructive actions are taken. - if (hasManagedUsersFeature) { - logger.warn( - { subscriptionId: subscription._id }, - 'expired subscription has managedUsers feature enabled, skipping deletion' - ) - } else { - let hasGroupSSOEnabled = false - if (subscription?.ssoConfig) { - const ssoConfig = await SSOConfig.findOne({ - _id: subscription.ssoConfig._id || subscription.ssoConfig, - }) - .lean() - .exec() - if (ssoConfig.enabled) { - hasGroupSSOEnabled = true - } - } - - if (hasGroupSSOEnabled) { - logger.warn( - { subscriptionId: subscription._id }, - 'expired subscription has groupSSO feature enabled, skipping deletion' - ) - } else { - await deleteSubscription(subscription, requesterData) - } - } + await handleExpiredSubscription(subscription, requesterData) return } const updatedPlanCode = recurlySubscription.plan.plan_code @@ -450,6 +419,41 @@ async function _sendUserGroupPlanCodeUserProperty(userId) { } } +async function handleExpiredSubscription(subscription, requesterData) { + const hasManagedUsersFeature = + Features.hasFeature('saas') && subscription?.managedUsersEnabled + + // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to + // make sure that the group continues as-is and no destructive actions are taken. + if (hasManagedUsersFeature) { + logger.warn( + { subscriptionId: subscription._id }, + 'expired subscription has managedUsers feature enabled, skipping deletion' + ) + } else { + let hasGroupSSOEnabled = false + if (subscription?.ssoConfig) { + const ssoConfig = await SSOConfig.findOne({ + _id: subscription.ssoConfig._id || subscription.ssoConfig, + }) + .lean() + .exec() + if (ssoConfig.enabled) { + hasGroupSSOEnabled = true + } + } + + if (hasGroupSSOEnabled) { + logger.warn( + { subscriptionId: subscription._id }, + 'expired subscription has groupSSO feature enabled, skipping deletion' + ) + } else { + await deleteSubscription(subscription, requesterData) + } + } +} + async function _sendSubscriptionEvent(userId, subscriptionId, event) { const subscription = await Subscription.findOne( { _id: subscriptionId }, @@ -568,5 +572,6 @@ module.exports = { setRestorePoint, setSubscriptionWasReverted, voidRestorePoint, + handleExpiredSubscription, }, } From 86e13b088a34ddee1a68acd989f3b067a9239cc1 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:15 +0100 Subject: [PATCH 005/209] Merge pull request #25938 from overleaf/mj-core-pug-teardown [web] Tear down core-pug-bs5 feature flag GitOrigin-RevId: 875417ca02d8212940b4782bc3016778344116ba --- .../Features/Collaborators/CollaboratorsInviteController.mjs | 4 ---- .../web/app/src/Features/Templates/TemplatesController.js | 4 ---- services/web/app/views/project/editor/new_from_template.pug | 2 -- services/web/app/views/project/invite/not-valid.pug | 4 ---- services/web/app/views/project/invite/show.pug | 4 ---- 5 files changed, 18 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index 4c2d911709..db853afac3 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -16,7 +16,6 @@ import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js' import Errors from '../Errors/Errors.js' import AuthenticationController from '../Authentication/AuthenticationController.js' import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' -import SplitTestHandler from '../SplitTests/SplitTestHandler.js' // This rate limiter allows a different number of requests depending on the // number of callaborators a user is allowed. This is implemented by providing @@ -246,9 +245,6 @@ async function viewInvite(req, res) { const projectId = req.params.Project_id const { token } = req.params - // Read split test assignment so that it's available for Pug to read - await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') - const _renderInvalidPage = function () { res.status(404) logger.debug({ projectId }, 'invite not valid, rendering not-valid page') diff --git a/services/web/app/src/Features/Templates/TemplatesController.js b/services/web/app/src/Features/Templates/TemplatesController.js index a8730a61be..39c4d50ae0 100644 --- a/services/web/app/src/Features/Templates/TemplatesController.js +++ b/services/web/app/src/Features/Templates/TemplatesController.js @@ -4,13 +4,9 @@ const TemplatesManager = require('./TemplatesManager') const ProjectHelper = require('../Project/ProjectHelper') const logger = require('@overleaf/logger') const { expressify } = require('@overleaf/promise-utils') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') const TemplatesController = { async getV1Template(req, res) { - // Read split test assignment so that it's available for Pug to read - await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') - const templateVersionId = req.params.Template_version_id const templateId = req.query.id if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index f2945b20f1..b1b5ae1e25 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -4,8 +4,6 @@ block vars - var suppressFooter = true - var suppressCookieBanner = true - var suppressSkipToContent = true - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' block content .editor.full-size diff --git a/services/web/app/views/project/invite/not-valid.pug b/services/web/app/views/project/invite/not-valid.pug index 693c162205..b4cbc1be1b 100644 --- a/services/web/app/views/project/invite/not-valid.pug +++ b/services/web/app/views/project/invite/not-valid.pug @@ -1,9 +1,5 @@ extends ../../layout-marketing -block vars - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' - block content main.content.content-alt#main-content .container diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug index 35926977e2..a18518c716 100644 --- a/services/web/app/views/project/invite/show.pug +++ b/services/web/app/views/project/invite/show.pug @@ -1,9 +1,5 @@ extends ../../layout-marketing -block vars - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' - block content main.content.content-alt#main-content .container From 1b15dc38542ec7b1a6e4fea7515200c016367f22 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:25 +0100 Subject: [PATCH 006/209] Merge pull request #26003 from overleaf/mj-ide-duplicate-project [web] Editor redesign: Add project duplication button GitOrigin-RevId: 93e5aa66a7ccc13650e07fda041394811874dafa --- .../components/toolbar/duplicate-project.tsx | 48 +++++++++++++++++++ .../components/toolbar/project-title.tsx | 2 + 2 files changed, 50 insertions(+) create mode 100644 services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx new file mode 100644 index 0000000000..74f868cc91 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx @@ -0,0 +1,48 @@ +import EditorCloneProjectModalWrapper from '@/features/clone-project-modal/components/editor-clone-project-modal-wrapper' +import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useLocation } from '@/shared/hooks/use-location' +import getMeta from '@/utils/meta' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type ProjectCopyResponse = { + project_id: string +} + +export const DuplicateProject = () => { + const { sendEvent } = useEditorAnalytics() + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const location = useLocation() + const anonymous = getMeta('ol-anonymous') + + const openProject = useCallback( + ({ project_id: projectId }: ProjectCopyResponse) => { + location.assign(`/project/${projectId}`) + }, + [location] + ) + + const handleShowModal = useCallback(() => { + sendEvent('copy-project', { location: 'project-title-dropdown' }) + setShowModal(true) + }, [sendEvent]) + + if (anonymous) { + return null + } + + return ( + <> + + {t('copy')} + + setShowModal(false)} + openProject={openProject} + /> + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx index 68860da4ea..61e29023a0 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx @@ -13,6 +13,7 @@ import { DownloadProjectPDF, DownloadProjectZip } from './download-project' import { useCallback, useState } from 'react' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' import EditableLabel from './editable-label' +import { DuplicateProject } from './duplicate-project' const [publishModalModules] = importOverleafModules('publishModal') const SubmitProjectButton = publishModalModules?.import.NewPublishToolbarButton @@ -73,6 +74,7 @@ export const ToolbarProjectTitle = () => { + { setIsRenaming(true) From da449f9f5fc684d61e7964b2947375e62d43e9b9 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:40 +0100 Subject: [PATCH 007/209] Merge pull request #26015 from overleaf/mj-ide-breadcrumbs-setting [web] Add setting to control editor breadcrumbs GitOrigin-RevId: 6e0a4bb97eba63a1df43d85840f8962bf0238b7c --- .../src/Features/Project/ProjectController.js | 1 + .../app/src/Features/User/UserController.js | 3 ++ services/web/app/src/models/User.js | 1 + .../web/frontend/extracted-translations.json | 3 ++ .../context/project-settings-context.tsx | 7 +++++ .../hooks/use-user-wide-settings.tsx | 10 +++++++ .../editor-settings/breadcrumbs-setting.tsx | 18 ++++++++++++ .../editor-settings/editor-settings.tsx | 2 ++ .../components/toolbar/menu-bar.tsx | 29 +++++++++++-------- .../components/codemirror-toolbar.tsx | 6 +++- .../shared/context/user-settings-context.tsx | 1 + services/web/locales/en.json | 3 ++ services/web/types/user-settings.ts | 1 + 13 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index ec128ffd54..842215d80e 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -824,6 +824,7 @@ const _ProjectController = { lineHeight: user.ace.lineHeight || 'normal', overallTheme: user.ace.overallTheme, mathPreview: user.ace.mathPreview, + breadcrumbs: user.ace.breadcrumbs, referencesSearchMode: user.ace.referencesSearchMode, enableNewEditor: user.ace.enableNewEditor ?? true, }, diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index e4186d39a8..b767dcd4a1 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -387,6 +387,9 @@ async function updateUserSettings(req, res, next) { if (req.body.mathPreview != null) { user.ace.mathPreview = req.body.mathPreview } + if (req.body.breadcrumbs != null) { + user.ace.breadcrumbs = Boolean(req.body.breadcrumbs) + } if (req.body.referencesSearchMode != null) { const mode = req.body.referencesSearchMode === 'simple' ? 'simple' : 'advanced' diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index d228c46b82..c1701023c4 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -97,6 +97,7 @@ const UserSchema = new Schema( fontFamily: { type: String }, lineHeight: { type: String }, mathPreview: { type: Boolean, default: true }, + breadcrumbs: { type: Boolean, default: true }, referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple' enableNewEditor: { type: Boolean }, }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9862e47817..09c2ba90dc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -186,6 +186,7 @@ "blog": "", "bold": "", "booktabs": "", + "breadcrumbs": "", "browser": "", "bullet_list": "", "buy_licenses": "", @@ -1543,6 +1544,8 @@ "sharelatex_beta_program": "", "shortcut_to_open_advanced_reference_search": "", "show_all_projects": "", + "show_breadcrumbs": "", + "show_breadcrumbs_in_toolbar": "", "show_document_preamble": "", "show_equation_preview": "", "show_file_tree": "", diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index e40c4c6872..e5cd576ba1 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx @@ -27,6 +27,7 @@ type ProjectSettingsSetterContextValue = { setLineHeight: (lineHeight: UserSettings['lineHeight']) => void setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void setMathPreview: (mathPreview: UserSettings['mathPreview']) => void + setBreadcrumbs: (breadcrumbs: UserSettings['breadcrumbs']) => void } type ProjectSettingsContextValue = Partial & @@ -74,6 +75,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, } = useUserWideSettings() useProjectWideSettingsSocketListener() @@ -110,6 +113,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, }), [ compiler, @@ -142,6 +147,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, ] ) diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index 70202c9446..978148721a 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx @@ -20,6 +20,7 @@ export default function useUserWideSettings() { lineHeight, pdfViewer, mathPreview, + breadcrumbs, } = userSettings const setOverallTheme = useSetOverallTheme() @@ -93,6 +94,13 @@ export default function useUserWideSettings() { [saveUserSettings] ) + const setBreadcrumbs = useCallback( + (breadcrumbs: UserSettings['breadcrumbs']) => { + saveUserSettings('breadcrumbs', breadcrumbs) + }, + [saveUserSettings] + ) + return { autoComplete, setAutoComplete, @@ -116,5 +124,7 @@ export default function useUserWideSettings() { setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, } } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx new file mode 100644 index 0000000000..c4dff10485 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx @@ -0,0 +1,18 @@ +import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context' +import ToggleSetting from '../toggle-setting' +import { useTranslation } from 'react-i18next' + +export default function BreadcrumbsSetting() { + const { breadcrumbs, setBreadcrumbs } = useProjectSettingsContext() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx index 28dcef8a9b..a58b0c101e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx @@ -9,6 +9,7 @@ import PDFViewerSetting from './pdf-viewer-setting' import SpellCheckSetting from './spell-check-setting' import DictionarySetting from './dictionary-setting' import importOverleafModules from '../../../../../../macros/import-overleaf-module.macro' +import BreadcrumbsSetting from './breadcrumbs-setting' const [referenceSearchSettingModule] = importOverleafModules( 'referenceSearchSetting' @@ -33,6 +34,7 @@ export default function EditorSettings() { + diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx index ed0ebd77f8..68f4772644 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx @@ -20,12 +20,12 @@ import CommandDropdown, { MenuSectionStructure, MenuStructure, } from './command-dropdown' -import { useUserSettingsContext } from '@/shared/context/user-settings-context' import { useRailContext } from '../../contexts/rail-context' import WordCountModal from '@/features/word-count-modal/components/word-count-modal' import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context' export const ToolbarMenuBar = () => { const { t } = useTranslation() @@ -170,19 +170,16 @@ export const ToolbarMenuBar = () => { [t] ) - const { - userSettings: { mathPreview }, - setUserSettings, - } = useUserSettingsContext() + const { mathPreview, setMathPreview, breadcrumbs, setBreadcrumbs } = + useProjectSettingsContext() const toggleMathPreview = useCallback(() => { - setUserSettings(prev => { - return { - ...prev, - mathPreview: !prev.mathPreview, - } - }) - }, [setUserSettings]) + setMathPreview(!mathPreview) + }, [setMathPreview, mathPreview]) + + const toggleBreadcrumbs = useCallback(() => { + setBreadcrumbs(!breadcrumbs) + }, [setBreadcrumbs, breadcrumbs]) const { setActiveModal } = useRailContext() const openKeyboardShortcutsModal = useCallback(() => { @@ -212,6 +209,14 @@ export const ToolbarMenuBar = () => { Editor settings + + } + onClick={toggleBreadcrumbs} + /> { const view = useCodeMirrorViewContext() @@ -41,6 +42,9 @@ const Toolbar = memo(function Toolbar() { const { t } = useTranslation() const state = useCodeMirrorStateContext() const view = useCodeMirrorViewContext() + const { + userSettings: { breadcrumbs }, + } = useUserSettingsContext() const [overflowed, setOverflowed] = useState(false) @@ -192,7 +196,7 @@ const Toolbar = memo(function Toolbar() { - {newEditor && } + {newEditor && breadcrumbs && } ) diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index b0bce5bf5c..b368371013 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -29,6 +29,7 @@ const defaultSettings: UserSettings = { mathPreview: true, referencesSearchMode: 'advanced', enableNewEditor: true, + breadcrumbs: true, } type UserSettingsContextValue = { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 910621f51a..9e0f86e8b9 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -240,6 +240,7 @@ "blog": "Blog", "bold": "Bold", "booktabs": "Booktabs", + "breadcrumbs": "Breadcrumbs", "brl_discount_offer_plans_page_banner": "__flag__ Great news! We’ve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.", "browser": "Browser", "built_in": "Built-In", @@ -2015,6 +2016,8 @@ "sharelatex_beta_program": "__appName__ Beta Program", "shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__)", "show_all_projects": "Show all projects", + "show_breadcrumbs": "Show breadcrumbs", + "show_breadcrumbs_in_toolbar": "Show breadcrumbs in toolbar", "show_document_preamble": "Show document preamble", "show_equation_preview": "Show equation preview", "show_file_tree": "Show file tree", diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 3e748d937e..add460edfa 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -17,4 +17,5 @@ export type UserSettings = { mathPreview: boolean referencesSearchMode: 'advanced' | 'simple' enableNewEditor: boolean + breadcrumbs: boolean } From 2e50e0ffa189501750afc3dbd5125643e8241296 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:33:07 +0200 Subject: [PATCH 008/209] [web] add ProjectAccess helper class (#25663) * [web] add ProjectAccess helper class * [web] remove ts-ignore for calling OError.tag with try/catch error GitOrigin-RevId: e097a95b4d929a3927a3eeb70635590680c93007 --- .../Collaborators/CollaboratorsGetter.js | 260 +++++++++++++----- 1 file changed, 191 insertions(+), 69 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index caa6ef159d..2906edad4e 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -1,3 +1,4 @@ +// @ts-check const { callbackify } = require('util') const pLimit = require('p-limit') const { ObjectId } = require('mongodb-legacy') @@ -50,7 +51,155 @@ module.exports = { }, } -async function getMemberIdsWithPrivilegeLevels(projectId) { +/** + * @typedef ProjectMember + * @property {string} id + * @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel + * @property {typeof Sources[keyof Sources]} source + * @property {boolean} [pendingEditor] + * @property {boolean} [pendingReviewer] + */ + +/** + * @typedef LoadedProjectMember + * @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel + * @property {{_id: ObjectId, email: string, features: any, first_name: string, last_name: string, signUpDate: Date}} user + * @property {boolean} [pendingEditor] + * @property {boolean} [pendingReviewer] + */ + +// Wrapper for determining multiple dimensions of project access. +class ProjectAccess { + /** @type {ProjectMember[]} */ + #members + + /** @type {typeof PublicAccessLevels[keyof PublicAccessLevels]} */ + #publicAccessLevel + + /** + * @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: typeof PublicAccessLevels[keyof PublicAccessLevels]; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; }} project + */ + constructor(project) { + this.#members = _getMemberIdsWithPrivilegeLevelsFromFields( + project.owner_ref, + project.collaberator_refs, + project.readOnly_refs, + project.tokenAccessReadAndWrite_refs, + project.tokenAccessReadOnly_refs, + project.publicAccesLevel, + project.pendingEditor_refs, + project.reviewer_refs, + project.pendingReviewer_refs + ) + this.#publicAccessLevel = project.publicAccesLevel + } + + /** + * @return {Promise} + */ + async loadInvitedMembers() { + return _loadMembers(this.#members.filter(m => m.source !== Sources.TOKEN)) + } + + /** + * @return {ProjectMember[]} + */ + allMembers() { + return this.#members + } + + /** + * @return {typeof PublicAccessLevels[keyof PublicAccessLevels]} + */ + publicAccessLevel() { + return this.#publicAccessLevel + } + + /** + * @return {string[]} + */ + memberIds() { + return this.#members.map(m => m.id) + } + + /** + * @return {string[]} + */ + invitedMemberIds() { + return this.#members.filter(m => m.source !== Sources.TOKEN).map(m => m.id) + } + + /** + * @param {string | ObjectId} userId + * @return {typeof PrivilegeLevels[keyof PrivilegeLevels]} + */ + privilegeLevelForUser(userId) { + for (const member of this.#members) { + if (member.id === userId.toString()) { + return member.privilegeLevel + } + } + return PrivilegeLevels.NONE + } + + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserInvitedMember(userId) { + for (const member of this.#members) { + if (member.id === userId.toString() && member.source !== Sources.TOKEN) { + return true + } + } + return false + } + + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserInvitedReadWriteMember(userId) { + for (const member of this.#members) { + if ( + member.id.toString() === userId.toString() && + member.source !== Sources.TOKEN && + member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE + ) { + return true + } + } + return false + } + + /** + * Counts invited members with editor or reviewer roles + * @return {number} + */ + countInvitedEditCollaborators() { + return this.#members.filter( + m => + m.source === Sources.INVITE && + (m.privilegeLevel === PrivilegeLevels.READ_AND_WRITE || + m.privilegeLevel === PrivilegeLevels.REVIEW) + ).length + } + + /** + * Counts invited members that are readonly pending editors or pending reviewers + * @return {number} + */ + countInvitedPendingEditors() { + return this.#members.filter( + m => + m.source === Sources.INVITE && + m.privilegeLevel === PrivilegeLevels.READ_ONLY && + (m.pendingEditor || m.pendingReviewer) + ).length + } +} + +async function getProjectAccess(projectId) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, collaberator_refs: 1, @@ -65,34 +214,23 @@ async function getMemberIdsWithPrivilegeLevels(projectId) { if (!project) { throw new Errors.NotFoundError(`no project found with id ${projectId}`) } - const memberIds = _getMemberIdsWithPrivilegeLevelsFromFields( - project.owner_ref, - project.collaberator_refs, - project.readOnly_refs, - project.tokenAccessReadAndWrite_refs, - project.tokenAccessReadOnly_refs, - project.publicAccesLevel, - project.pendingEditor_refs, - project.reviewer_refs, - project.pendingReviewer_refs - ) - return memberIds + return new ProjectAccess(project) +} + +async function getMemberIdsWithPrivilegeLevels(projectId) { + return (await getProjectAccess(projectId)).allMembers() } async function getMemberIds(projectId) { - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.map(m => m.id) + return (await getProjectAccess(projectId)).memberIds() } async function getInvitedMemberIds(projectId) { - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter(m => m.source !== Sources.TOKEN).map(m => m.id) + return (await getProjectAccess(projectId)).invitedMemberIds() } async function getInvitedMembersWithPrivilegeLevels(projectId) { - let members = await getMemberIdsWithPrivilegeLevels(projectId) - members = members.filter(m => m.source !== Sources.TOKEN) - return _loadMembers(members) + return await (await getProjectAccess(projectId)).loadInvitedMembers() } async function getInvitedMembersWithPrivilegeLevelsFromFields( @@ -107,7 +245,7 @@ async function getInvitedMembersWithPrivilegeLevelsFromFields( readOnlyIds, [], [], - null, + 'private', [], reviewerIds, [] @@ -121,69 +259,31 @@ async function getMemberIdPrivilegeLevel(userId, projectId) { if (userId == null) { return PrivilegeLevels.NONE } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if (member.id === userId.toString()) { - return member.privilegeLevel - } - } - return PrivilegeLevels.NONE + return (await getProjectAccess(projectId)).privilegeLevelForUser(userId) } async function getInvitedEditCollaboratorCount(projectId) { - // Counts invited members with editor or reviewer roles - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter( - m => - m.source === Sources.INVITE && - (m.privilegeLevel === PrivilegeLevels.READ_AND_WRITE || - m.privilegeLevel === PrivilegeLevels.REVIEW) - ).length + return (await getProjectAccess(projectId)).countInvitedEditCollaborators() } async function getInvitedPendingEditorCount(projectId) { - // Only counts invited members that are readonly pending editors or pending - // reviewers - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter( - m => - m.source === Sources.INVITE && - m.privilegeLevel === PrivilegeLevels.READ_ONLY && - (m.pendingEditor || m.pendingReviewer) - ).length + return (await getProjectAccess(projectId)).countInvitedPendingEditors() } async function isUserInvitedMemberOfProject(userId, projectId) { if (!userId) { return false } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if ( - member.id.toString() === userId.toString() && - member.source !== Sources.TOKEN - ) { - return true - } - } - return false + return (await getProjectAccess(projectId)).isUserInvitedMember(userId) } async function isUserInvitedReadWriteMemberOfProject(userId, projectId) { if (!userId) { return false } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if ( - member.id.toString() === userId.toString() && - member.source !== Sources.TOKEN && - member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE - ) { - return true - } - } - return false + return (await getProjectAccess(projectId)).isUserInvitedReadWriteMember( + userId + ) } async function getPublicShareTokens(userId, projectId) { @@ -209,10 +309,13 @@ async function getPublicShareTokens(userId, projectId) { return null } + // @ts-ignore if (memberInfo.isOwner) { return memberInfo.tokens + // @ts-ignore } else if (memberInfo.hasTokenReadOnlyAccess) { return { + // @ts-ignore readOnly: memberInfo.tokens.readOnly, } } else { @@ -224,6 +327,7 @@ async function getPublicShareTokens(userId, projectId) { // excluding projects where the user is listed in the token access fields when // token access has been disabled. async function getProjectsUserIsMemberOf(userId, fields) { + // @ts-ignore const limit = pLimit(2) const [readAndWrite, review, readOnly, tokenReadAndWrite, tokenReadOnly] = await Promise.all([ @@ -274,9 +378,9 @@ async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) { async function getAllInvitedMembers(projectId) { try { - const rawMembers = await getInvitedMembersWithPrivilegeLevels(projectId) - const { members } = - ProjectEditorHandler.buildOwnerAndMembersViews(rawMembers) + const { members } = ProjectEditorHandler.buildOwnerAndMembersViews( + await (await getProjectAccess(projectId)).loadInvitedMembers() + ) return members } catch (err) { throw OError.tag(err, 'error getting members for project', { projectId }) @@ -316,6 +420,19 @@ async function userIsReadWriteTokenMember(userId, projectId) { return project != null } +/** + * @param {ObjectId} ownerId + * @param {ObjectId[]} collaboratorIds + * @param {ObjectId[]} readOnlyIds + * @param {ObjectId[]} tokenAccessIds + * @param {ObjectId[]} tokenAccessReadOnlyIds + * @param {typeof PublicAccessLevels[keyof PublicAccessLevels]} publicAccessLevel + * @param {ObjectId[]} pendingEditorIds + * @param {ObjectId[]} reviewerIds + * @param {ObjectId[]} pendingReviewerIds + * @return {ProjectMember[]} + * @private + */ function _getMemberIdsWithPrivilegeLevelsFromFields( ownerId, collaboratorIds, @@ -384,6 +501,11 @@ function _getMemberIdsWithPrivilegeLevelsFromFields( return members } +/** + * @param {ProjectMember[]} members + * @return {Promise} + * @private + */ async function _loadMembers(members) { const userIds = Array.from(new Set(members.map(m => m.id))) const users = new Map() From 6cbacc8cb73afe55f7ecba67d5c5f7474a3a7373 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:34:39 +0200 Subject: [PATCH 009/209] [web] fetch project once for joinProject (#25667) * [web] fetch project once for joinProject * [web] await all the nested helpers for getting privilege levels Co-authored-by: Mathias Jakobsen --------- Co-authored-by: Mathias Jakobsen GitOrigin-RevId: f0280c36ef995b417ccdab15014f05954e18c5f0 --- .../Authorization/AuthorizationManager.js | 81 ++++++++-- .../Collaborators/CollaboratorsGetter.js | 19 +++ .../Features/Editor/EditorHttpController.js | 25 +-- .../AuthorizationManagerTests.js | 149 ++++++++++++++---- .../src/Editor/EditorHttpControllerTests.js | 50 ++++-- 5 files changed, 250 insertions(+), 74 deletions(-) diff --git a/services/web/app/src/Features/Authorization/AuthorizationManager.js b/services/web/app/src/Features/Authorization/AuthorizationManager.js index 2f339de83d..22d92ea9d9 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationManager.js +++ b/services/web/app/src/Features/Authorization/AuthorizationManager.js @@ -88,9 +88,54 @@ async function getPrivilegeLevelForProject( opts = {} ) { if (userId) { - return getPrivilegeLevelForProjectWithUser(userId, projectId, opts) + return await getPrivilegeLevelForProjectWithUser( + userId, + projectId, + null, + opts + ) } else { - return getPrivilegeLevelForProjectWithoutUser(projectId, token, opts) + return await getPrivilegeLevelForProjectWithoutUser(projectId, token, opts) + } +} + +/** + * Get the privilege level that the user has for the project. + * + * @param userId - The id of the user that wants to access the project. + * @param projectId - The id of the project to be accessed. + * @param {string} token + * @param {ProjectAccess} projectAccess + * @param {Object} opts + * @param {boolean} opts.ignoreSiteAdmin - Do not consider whether the user is + * a site admin. + * @param {boolean} opts.ignorePublicAccess - Do not consider the project is + * publicly accessible. + * + * @returns {string|boolean} The privilege level. One of "owner", + * "readAndWrite", "readOnly" or false. + */ +async function getPrivilegeLevelForProjectWithProjectAccess( + userId, + projectId, + token, + projectAccess, + opts = {} +) { + if (userId) { + return await getPrivilegeLevelForProjectWithUser( + userId, + projectId, + projectAccess, + opts + ) + } else { + return await _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + projectAccess.publicAccessLevel(), + opts + ) } } @@ -98,6 +143,7 @@ async function getPrivilegeLevelForProject( async function getPrivilegeLevelForProjectWithUser( userId, projectId, + projectAccess, opts = {} ) { if (!opts.ignoreSiteAdmin) { @@ -106,11 +152,11 @@ async function getPrivilegeLevelForProjectWithUser( } } - const privilegeLevel = - await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel( - userId, - projectId - ) + projectAccess = + projectAccess || + (await CollaboratorsGetter.promises.getProjectAccess(projectId)) + + const privilegeLevel = projectAccess.privilegeLevelForUser(userId) if (privilegeLevel && privilegeLevel !== PrivilegeLevels.NONE) { // The user has direct access return privilegeLevel @@ -119,7 +165,7 @@ async function getPrivilegeLevelForProjectWithUser( if (!opts.ignorePublicAccess) { // Legacy public-access system // User is present (not anonymous), but does not have direct access - const publicAccessLevel = await getPublicAccessLevel(projectId) + const publicAccessLevel = projectAccess.publicAccessLevel() if (publicAccessLevel === PublicAccessLevels.READ_ONLY) { return PrivilegeLevels.READ_ONLY } @@ -137,7 +183,21 @@ async function getPrivilegeLevelForProjectWithoutUser( token, opts = {} ) { - const publicAccessLevel = await getPublicAccessLevel(projectId) + return await _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + await getPublicAccessLevel(projectId), + opts + ) +} + +// User is Anonymous, Try Token-based access +async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + publicAccessLevel, + opts = {} +) { if (!opts.ignorePublicAccess) { if (publicAccessLevel === PublicAccessLevels.READ_ONLY) { // Legacy public read-only access for anonymous user @@ -149,7 +209,7 @@ async function getPrivilegeLevelForProjectWithoutUser( } } if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) { - return getPrivilegeLevelForProjectWithToken(projectId, token) + return await getPrivilegeLevelForProjectWithToken(projectId, token) } // Deny anonymous user access @@ -309,6 +369,7 @@ module.exports = { canUserRenameProject, canUserAdminProject, getPrivilegeLevelForProject, + getPrivilegeLevelForProjectWithProjectAccess, isRestrictedUserForProject, isUserSiteAdmin, }, diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 2906edad4e..10c8e53757 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -32,6 +32,7 @@ module.exports = { userIsTokenMember: callbackify(userIsTokenMember), getAllInvitedMembers: callbackify(getAllInvitedMembers), promises: { + getProjectAccess, getMemberIdsWithPrivilegeLevels, getMemberIds, getInvitedMemberIds, @@ -134,6 +135,7 @@ class ProjectAccess { * @return {typeof PrivilegeLevels[keyof PrivilegeLevels]} */ privilegeLevelForUser(userId) { + if (!userId) return PrivilegeLevels.NONE for (const member of this.#members) { if (member.id === userId.toString()) { return member.privilegeLevel @@ -142,11 +144,26 @@ class ProjectAccess { return PrivilegeLevels.NONE } + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserTokenMember(userId) { + if (!userId) return false + for (const member of this.#members) { + if (member.id === userId.toString() && member.source === Sources.TOKEN) { + return true + } + } + return false + } + /** * @param {string | ObjectId} userId * @return {boolean} */ isUserInvitedMember(userId) { + if (!userId) return false for (const member of this.#members) { if (member.id === userId.toString() && member.source !== Sources.TOKEN) { return true @@ -199,6 +216,8 @@ class ProjectAccess { } } +module.exports.ProjectAccess = ProjectAccess + async function getProjectAccess(projectId) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index 8128a95b26..def7face04 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -4,14 +4,13 @@ const ProjectGetter = require('../Project/ProjectGetter') const AuthorizationManager = require('../Authorization/AuthorizationManager') const ProjectEditorHandler = require('../Project/ProjectEditorHandler') const Metrics = require('@overleaf/metrics') -const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') const CollaboratorsInviteGetter = require('../Collaborators/CollaboratorsInviteGetter') -const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const SessionManager = require('../Authentication/SessionManager') const Errors = require('../Errors/Errors') const { expressify } = require('@overleaf/promise-utils') const Settings = require('@overleaf/settings') +const { ProjectAccess } = require('../Collaborators/CollaboratorsGetter') module.exports = { joinProject: expressify(joinProject), @@ -75,31 +74,23 @@ async function _buildJoinProjectView(req, projectId, userId) { if (project == null) { throw new Errors.NotFoundError('project not found') } - const members = - await CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels( - projectId - ) + const projectAccess = new ProjectAccess(project) + const members = await projectAccess.loadInvitedMembers() const token = req.body.anonymousAccessToken const privilegeLevel = - await AuthorizationManager.promises.getPrivilegeLevelForProject( + await AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess( userId, projectId, - token + token, + projectAccess ) if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { return { project: null, privilegeLevel: null, isRestrictedUser: false } } const invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) - const isTokenMember = await CollaboratorsHandler.promises.userIsTokenMember( - userId, - projectId - ) - const isInvitedMember = - await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( - userId, - projectId - ) + const isTokenMember = projectAccess.isUserTokenMember(userId) + const isInvitedMember = projectAccess.isUserInvitedMember(userId) const isRestrictedUser = AuthorizationManager.isRestrictedUser( userId, privilegeLevel, diff --git a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js index 7463bbdeb7..e4c67d2f77 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js @@ -27,7 +27,10 @@ describe('AuthorizationManager', function () { this.CollaboratorsGetter = { promises: { - getMemberIdPrivilegeLevel: sinon.stub().resolves(PrivilegeLevels.NONE), + getProjectAccess: sinon.stub().resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon.stub().returns(PrivilegeLevels.NONE), + }), }, } @@ -113,9 +116,17 @@ describe('AuthorizationManager', function () { describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -171,8 +182,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -204,8 +215,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -237,8 +248,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -264,9 +275,17 @@ describe('AuthorizationManager', function () { describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -321,8 +340,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -336,13 +355,32 @@ describe('AuthorizationManager', function () { describe('with a public project', function () { beforeEach(function () { this.project.publicAccesLevel = 'readAndWrite' + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.NONE), + }) }) describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -397,8 +435,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -410,6 +448,11 @@ describe('AuthorizationManager', function () { }) describe("when the project doesn't exist", function () { + beforeEach(function () { + this.CollaboratorsGetter.promises.getProjectAccess.rejects( + new Errors.NotFoundError() + ) + }) it('should return a NotFoundError', async function () { const someOtherId = new ObjectId() await expect( @@ -424,9 +467,15 @@ describe('AuthorizationManager', function () { describe('when the project id is not valid', function () { beforeEach(function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) }) it('should return a error', async function () { @@ -529,9 +578,15 @@ describe('AuthorizationManager', function () { describe('canUserDeleteOrResolveThread', 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) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_AND_WRITE), + }) const canResolve = await this.AuthorizationManager.promises.canUserDeleteOrResolveThread( @@ -546,9 +601,15 @@ describe('AuthorizationManager', function () { }) 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) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) const canResolve = await this.AuthorizationManager.promises.canUserDeleteOrResolveThread( @@ -564,9 +625,15 @@ describe('AuthorizationManager', function () { describe('when user has review permission', function () { beforeEach(function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.REVIEW) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.REVIEW), + }) }) it('should return false when user is not the comment author', async function () { @@ -691,15 +758,27 @@ function testPermission(permission, privilegeLevels) { function setupUserPrivilegeLevel(privilegeLevel) { beforeEach(`set user privilege level to ${privilegeLevel}`, function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(privilegeLevel) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(privilegeLevel), + }) }) } function setupPublicAccessLevel(level) { beforeEach(`set public access level to ${level}`, function () { this.project.publicAccesLevel = level + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon.stub().returns(PrivilegeLevels.NONE), + }) }) } diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index dffa2d21ff..f9fcf4362e 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -51,10 +51,25 @@ describe('EditorHttpController', function () { this.AuthorizationManager = { isRestrictedUser: sinon.stub().returns(false), promises: { - getPrivilegeLevelForProject: sinon.stub().resolves('owner'), + getPrivilegeLevelForProjectWithProjectAccess: sinon + .stub() + .resolves('owner'), }, } this.CollaboratorsGetter = { + ProjectAccess: class { + loadInvitedMembers() { + return [] + } + + isUserTokenMember() { + return false + } + + isUserInvitedMember() { + return false + } + }, promises: { getInvitedMembersWithPrivilegeLevels: sinon .stub() @@ -170,9 +185,12 @@ describe('EditorHttpController', function () { describe('successfully', function () { beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - true - ) + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(true) this.res.callback = done this.EditorHttpController.joinProject(this.req, this.res) }) @@ -214,7 +232,7 @@ describe('EditorHttpController', function () { describe('with a restricted user', function () { beforeEach(function (done) { this.AuthorizationManager.isRestrictedUser.returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readOnly' ) this.res.callback = done @@ -234,7 +252,7 @@ describe('EditorHttpController', function () { describe('when not authorized', function () { beforeEach(function (done) { - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( null ) this.res.callback = done @@ -258,7 +276,7 @@ describe('EditorHttpController', function () { this.AuthorizationManager.isRestrictedUser .withArgs(null, 'readOnly', false, false) .returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess .withArgs(null, this.project._id, this.token) .resolves('readOnly') this.EditorHttpController.joinProject(this.req, this.res) @@ -277,11 +295,19 @@ describe('EditorHttpController', function () { describe('with a token access user', function () { beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) - this.CollaboratorsHandler.promises.userIsTokenMember.resolves(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(false) + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserTokenMember' + ) + .returns(true) + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readAndWrite' ) this.res.callback = done From 0aae5c48b4632e5efaf25aa7111b90d834bf8ff6 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:38:40 +0200 Subject: [PATCH 010/209] [web] skip fetching members and invites for restricted users (#25673) * [web] hide sensitive data from joinProject when building project view * [web] skip fetching members and invites for restricted users * [web] fix owner features in joinProject view * [web] separate invited members from owner * [web] skip fetching users with empty members list * [web] split await chain Co-authored-by: Antoine Clausse * [web] remove spurious parentheses * [web] remove dead code Co-authored-by: Antoine Clausse --------- Co-authored-by: Antoine Clausse GitOrigin-RevId: 5b4d874f974971e9c14d7412620805f8ebf63541 --- .../Collaborators/CollaboratorsGetter.js | 47 +++-- .../Features/Editor/EditorHttpController.js | 23 +- .../Features/Project/ProjectEditorHandler.js | 45 ++-- .../Collaborators/CollaboratorsGetterTests.js | 60 ++---- .../src/Editor/EditorHttpControllerTests.js | 83 ++++++-- .../src/Project/ProjectEditorHandlerTests.js | 199 +++++++++++------- 6 files changed, 268 insertions(+), 189 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 10c8e53757..a3543ae614 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -16,9 +16,6 @@ module.exports = { getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels), getMemberIds: callbackify(getMemberIds), getInvitedMemberIds: callbackify(getInvitedMemberIds), - getInvitedMembersWithPrivilegeLevels: callbackify( - getInvitedMembersWithPrivilegeLevels - ), getInvitedMembersWithPrivilegeLevelsFromFields: callbackify( getInvitedMembersWithPrivilegeLevelsFromFields ), @@ -36,7 +33,6 @@ module.exports = { getMemberIdsWithPrivilegeLevels, getMemberIds, getInvitedMemberIds, - getInvitedMembersWithPrivilegeLevels, getInvitedMembersWithPrivilegeLevelsFromFields, getMemberIdPrivilegeLevel, getInvitedEditCollaboratorCount, @@ -95,11 +91,40 @@ class ProjectAccess { this.#publicAccessLevel = project.publicAccesLevel } + /** + * @return {Promise<{ownerMember: LoadedProjectMember|undefined, members: LoadedProjectMember[]}>} + */ + async loadOwnerAndInvitedMembers() { + const all = await _loadMembers( + this.#members.filter(m => m.source !== Sources.TOKEN) + ) + return { + ownerMember: all.find(m => m.privilegeLevel === PrivilegeLevels.OWNER), + members: all.filter(m => m.privilegeLevel !== PrivilegeLevels.OWNER), + } + } + /** * @return {Promise} */ async loadInvitedMembers() { - return _loadMembers(this.#members.filter(m => m.source !== Sources.TOKEN)) + return _loadMembers( + this.#members.filter( + m => + m.source !== Sources.TOKEN && + m.privilegeLevel !== PrivilegeLevels.OWNER + ) + ) + } + + /** + * @return {Promise} + */ + async loadOwner() { + const [owner] = await _loadMembers( + this.#members.filter(m => m.privilegeLevel === PrivilegeLevels.OWNER) + ) + return owner } /** @@ -248,10 +273,6 @@ async function getInvitedMemberIds(projectId) { return (await getProjectAccess(projectId)).invitedMemberIds() } -async function getInvitedMembersWithPrivilegeLevels(projectId) { - return await (await getProjectAccess(projectId)).loadInvitedMembers() -} - async function getInvitedMembersWithPrivilegeLevelsFromFields( ownerId, collaboratorIds, @@ -397,10 +418,9 @@ async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) { async function getAllInvitedMembers(projectId) { try { - const { members } = ProjectEditorHandler.buildOwnerAndMembersViews( - await (await getProjectAccess(projectId)).loadInvitedMembers() - ) - return members + const projectAccess = await getProjectAccess(projectId) + const invitedMembers = await projectAccess.loadInvitedMembers() + return invitedMembers.map(ProjectEditorHandler.buildUserModelView) } catch (err) { throw OError.tag(err, 'error getting members for project', { projectId }) } @@ -526,6 +546,7 @@ function _getMemberIdsWithPrivilegeLevelsFromFields( * @private */ async function _loadMembers(members) { + if (members.length === 0) return [] const userIds = Array.from(new Set(members.map(m => m.id))) const users = new Map() for (const user of await UserGetter.promises.getUsers(userIds, { diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index def7face04..f44b57f069 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -42,12 +42,6 @@ async function joinProject(req, res, next) { if (!project) { return res.sendStatus(403) } - // Hide sensitive data if the user is restricted - if (isRestrictedUser) { - project.owner = { _id: project.owner._id } - project.members = [] - project.invites = [] - } // Only show the 'renamed or deleted' message once if (project.deletedByExternalDataSource) { await ProjectDeleter.promises.unmarkAsDeletedByExternalSource(projectId) @@ -75,7 +69,6 @@ async function _buildJoinProjectView(req, projectId, userId) { throw new Errors.NotFoundError('project not found') } const projectAccess = new ProjectAccess(project) - const members = await projectAccess.loadInvitedMembers() const token = req.body.anonymousAccessToken const privilegeLevel = await AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess( @@ -87,8 +80,6 @@ async function _buildJoinProjectView(req, projectId, userId) { if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { return { project: null, privilegeLevel: null, isRestrictedUser: false } } - const invites = - await CollaboratorsInviteGetter.promises.getAllInvites(projectId) const isTokenMember = projectAccess.isUserTokenMember(userId) const isInvitedMember = projectAccess.isUserInvitedMember(userId) const isRestrictedUser = AuthorizationManager.isRestrictedUser( @@ -97,11 +88,23 @@ async function _buildJoinProjectView(req, projectId, userId) { isTokenMember, isInvitedMember ) + let ownerMember + let members = [] + let invites = [] + if (isRestrictedUser) { + ownerMember = await projectAccess.loadOwner() + } else { + ;({ ownerMember, members } = + await projectAccess.loadOwnerAndInvitedMembers()) + invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) + } return { project: ProjectEditorHandler.buildProjectModelView( project, + ownerMember, members, - invites + invites, + isRestrictedUser ), privilegeLevel, isTokenMember, diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 05e5beba09..3d3d300e66 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -6,8 +6,13 @@ const Features = require('../../infrastructure/Features') module.exports = ProjectEditorHandler = { trackChangesAvailable: false, - buildProjectModelView(project, members, invites) { - let owner, ownerFeatures + buildProjectModelView( + project, + ownerMember, + members, + invites, + isRestrictedUser + ) { const result = { _id: project._id, name: project.name, @@ -20,20 +25,23 @@ module.exports = ProjectEditorHandler = { description: project.description, spellCheckLanguage: project.spellCheckLanguage, deletedByExternalDataSource: project.deletedByExternalDataSource || false, - members: [], - invites: this.buildInvitesView(invites), imageName: project.imageName != null ? Path.basename(project.imageName) : undefined, } - ;({ owner, ownerFeatures, members } = - this.buildOwnerAndMembersViews(members)) - result.owner = owner - result.members = members + if (isRestrictedUser) { + result.owner = { _id: project.owner_ref } + result.members = [] + result.invites = [] + } else { + result.owner = this.buildUserModelView(ownerMember) + result.members = members.map(this.buildUserModelView) + result.invites = this.buildInvitesView(invites) + } - result.features = _.defaults(ownerFeatures || {}, { + result.features = _.defaults(ownerMember?.user?.features || {}, { collaborators: -1, // Infinite versioning: false, dropbox: false, @@ -62,25 +70,6 @@ module.exports = ProjectEditorHandler = { return result }, - buildOwnerAndMembersViews(members) { - let owner = null - let ownerFeatures = null - const filteredMembers = [] - for (const member of members || []) { - if (member.privilegeLevel === 'owner') { - ownerFeatures = member.user.features - owner = this.buildUserModelView(member) - } else { - filteredMembers.push(this.buildUserModelView(member)) - } - } - return { - owner, - ownerFeatures, - members: filteredMembers, - } - }, - buildUserModelView(member) { const user = member.user return { diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js index dda99e04f3..10542c4564 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js @@ -62,7 +62,7 @@ describe('CollaboratorsGetter', function () { }, } this.ProjectEditorHandler = { - buildOwnerAndMembersViews: sinon.stub(), + buildUserModelView: sinon.stub(), } this.CollaboratorsGetter = SandboxedModule.require(MODULE_PATH, { requires: { @@ -204,30 +204,6 @@ describe('CollaboratorsGetter', function () { }) }) - describe('getInvitedMembersWithPrivilegeLevels', function () { - beforeEach(function () { - this.UserGetter.promises.getUsers.resolves([ - { _id: this.readOnlyRef1 }, - { _id: this.readOnlyTokenRef }, - { _id: this.readWriteRef2 }, - { _id: this.readWriteTokenRef }, - { _id: this.reviewer1Ref }, - ]) - }) - - it('should return an array of invited members with their privilege levels', async function () { - const result = - await this.CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels( - this.project._id - ) - expect(result).to.have.deep.members([ - { user: { _id: this.readOnlyRef1 }, privilegeLevel: 'readOnly' }, - { user: { _id: this.readWriteRef2 }, privilegeLevel: 'readAndWrite' }, - { user: { _id: this.reviewer1Ref }, privilegeLevel: 'review' }, - ]) - }) - }) - describe('getMemberIdPrivilegeLevel', function () { it('should return the privilege level if it exists', async function () { const level = @@ -401,20 +377,21 @@ describe('CollaboratorsGetter', function () { { user: this.readWriteUser, privilegeLevel: 'readAndWrite' }, { user: this.reviewUser, privilegeLevel: 'review' }, ] - this.views = { - owner: this.owningUser, - ownerFeatures: this.owningUser.features, - members: [ - { _id: this.readWriteUser._id, email: this.readWriteUser.email }, - { _id: this.reviewUser._id, email: this.reviewUser.email }, - ], - } + this.memberViews = [ + { _id: this.readWriteUser._id, email: this.readWriteUser.email }, + { _id: this.reviewUser._id, email: this.reviewUser.email }, + ] this.UserGetter.promises.getUsers.resolves([ this.owningUser, this.readWriteUser, this.reviewUser, ]) - this.ProjectEditorHandler.buildOwnerAndMembersViews.returns(this.views) + this.ProjectEditorHandler.buildUserModelView + .withArgs(this.members[1]) + .returns(this.memberViews[0]) + this.ProjectEditorHandler.buildUserModelView + .withArgs(this.members[2]) + .returns(this.memberViews[1]) this.result = await this.CollaboratorsGetter.promises.getAllInvitedMembers( this.project._id @@ -422,15 +399,18 @@ describe('CollaboratorsGetter', function () { }) it('should produce a list of members', function () { - expect(this.result).to.deep.equal(this.views.members) + expect(this.result).to.deep.equal(this.memberViews) }) - it('should call ProjectEditorHandler.buildOwnerAndMembersViews', function () { - expect(this.ProjectEditorHandler.buildOwnerAndMembersViews).to.have.been - .calledOnce + it('should call ProjectEditorHandler.buildUserModelView', function () { + expect(this.ProjectEditorHandler.buildUserModelView).to.have.been + .calledTwice expect( - this.ProjectEditorHandler.buildOwnerAndMembersViews - ).to.have.been.calledWith(this.members) + this.ProjectEditorHandler.buildUserModelView + ).to.have.been.calledWith(this.members[1]) + expect( + this.ProjectEditorHandler.buildUserModelView + ).to.have.been.calledWith(this.members[2]) }) }) diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index f9fcf4362e..7fc08c45d3 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -20,6 +20,12 @@ describe('EditorHttpController', function () { _id: new ObjectId(), projects: {}, } + this.members = [ + { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' }, + { user: { _id: 'one' }, privilegeLevel: 'readOnly' }, + ] + this.ownerMember = this.members[0] + this.invites = [{ _id: 'three' }, { _id: 'four' }] this.projectView = { _id: this.project._id, owner: { @@ -27,7 +33,10 @@ describe('EditorHttpController', function () { email: 'owner@example.com', other_property: true, }, - members: [{ one: 1 }, { two: 2 }], + members: [ + { _id: 'owner', privileges: 'owner' }, + { _id: 'one', privileges: 'readOnly' }, + ], invites: [{ three: 3 }, { four: 4 }], } this.reducedProjectView = { @@ -56,10 +65,16 @@ describe('EditorHttpController', function () { .resolves('owner'), }, } + const members = this.members + const ownerMember = this.ownerMember this.CollaboratorsGetter = { ProjectAccess: class { - loadInvitedMembers() { - return [] + loadOwnerAndInvitedMembers() { + return { members, ownerMember } + } + + loadOwner() { + return ownerMember } isUserTokenMember() { @@ -71,9 +86,6 @@ describe('EditorHttpController', function () { } }, promises: { - getInvitedMembersWithPrivilegeLevels: sinon - .stub() - .resolves(['members', 'mock']), isUserInvitedMemberOfProject: sinon.stub().resolves(false), }, } @@ -82,22 +94,23 @@ describe('EditorHttpController', function () { userIsTokenMember: sinon.stub().resolves(false), }, } + this.invites = [ + { + _id: 'invite_one', + email: 'user-one@example.com', + privileges: 'readOnly', + projectId: this.project._id, + }, + { + _id: 'invite_two', + email: 'user-two@example.com', + privileges: 'readOnly', + projectId: this.project._id, + }, + ] this.CollaboratorsInviteGetter = { promises: { - getAllInvites: sinon.stub().resolves([ - { - _id: 'invite_one', - email: 'user-one@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - { - _id: 'invite_two', - email: 'user-two@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - ]), + getAllInvites: sinon.stub().resolves(this.invites), }, } this.EditorController = { @@ -195,6 +208,18 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a full view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith( + this.project, + this.ownerMember, + this.members, + this.invites, + false + ) + }) + it('should return the project and privilege level', function () { expect(this.res.json).to.have.been.calledWith({ project: this.projectView, @@ -231,6 +256,9 @@ describe('EditorHttpController', function () { describe('with a restricted user', function () { beforeEach(function (done) { + this.ProjectEditorHandler.buildProjectModelView.returns( + this.reducedProjectView + ) this.AuthorizationManager.isRestrictedUser.returns(true) this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readOnly' @@ -239,6 +267,12 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a restricted view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) + }) + it('should mark the user as restricted, and hide details of owner', function () { expect(this.res.json).to.have.been.calledWith({ project: this.reducedProjectView, @@ -268,6 +302,9 @@ describe('EditorHttpController', function () { beforeEach(function (done) { this.token = 'token' this.TokenAccessHandler.getRequestToken.returns(this.token) + this.ProjectEditorHandler.buildProjectModelView.returns( + this.reducedProjectView + ) this.req.body = { userId: 'anonymous-user', anonymousAccessToken: this.token, @@ -282,6 +319,12 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a restricted view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) + }) + it('should mark the user as restricted', function () { expect(this.res.json).to.have.been.calledWith({ project: this.reducedProjectView, diff --git a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js index 0fb5b5fce4..8456fe2227 100644 --- a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js @@ -8,6 +8,7 @@ describe('ProjectEditorHandler', function () { beforeEach(function () { this.project = { _id: 'project-id', + owner_ref: 'owner-id', name: 'Project Name', rootDoc_id: 'file-id', publicAccesLevel: 'private', @@ -43,16 +44,19 @@ describe('ProjectEditorHandler', function () { }, ], } + this.ownerMember = { + user: (this.owner = { + _id: 'owner-id', + first_name: 'Owner', + last_name: 'Overleaf', + email: 'owner@overleaf.com', + features: { + compileTimeout: 240, + }, + }), + privilegeLevel: 'owner', + } this.members = [ - { - user: (this.owner = { - _id: 'owner-id', - first_name: 'Owner', - last_name: 'Overleaf', - email: 'owner@overleaf.com', - }), - privilegeLevel: 'owner', - }, { user: { _id: 'read-only-id', @@ -96,8 +100,10 @@ describe('ProjectEditorHandler', function () { beforeEach(function () { this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - this.invites + this.invites, + false ) }) @@ -206,6 +212,93 @@ describe('ProjectEditorHandler', function () { expect(invite.token).not.to.exist } }) + + it('should have the correct features', function () { + expect(this.result.features.compileTimeout).to.equal(240) + }) + }) + + describe('with a restricted user', function () { + beforeEach(function () { + this.result = this.handler.buildProjectModelView( + this.project, + this.ownerMember, + [], + [], + true + ) + }) + + it('should include the id', function () { + expect(this.result._id).to.exist + this.result._id.should.equal('project-id') + }) + + it('should include the name', function () { + expect(this.result.name).to.exist + this.result.name.should.equal('Project Name') + }) + + it('should include the root doc id', function () { + expect(this.result.rootDoc_id).to.exist + this.result.rootDoc_id.should.equal('file-id') + }) + + it('should include the public access level', function () { + expect(this.result.publicAccesLevel).to.exist + this.result.publicAccesLevel.should.equal('private') + }) + + it('should hide the owner', function () { + expect(this.result.owner).to.deep.equal({ _id: 'owner-id' }) + }) + + it('should hide members', function () { + this.result.members.length.should.equal(0) + }) + + it('should include folders in the project', function () { + this.result.rootFolder[0]._id.should.equal('root-folder-id') + this.result.rootFolder[0].name.should.equal('') + + this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') + this.result.rootFolder[0].folders[0].name.should.equal('folder') + }) + + it('should not duplicate folder contents', function () { + this.result.rootFolder[0].docs.length.should.equal(0) + this.result.rootFolder[0].fileRefs.length.should.equal(0) + }) + + it('should include files in the project', function () { + this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( + 'file-id' + ) + this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( + 'image.png' + ) + this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( + this.created + ) + expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to + .exist + }) + + it('should include docs in the project but not the lines', function () { + this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') + this.result.rootFolder[0].folders[0].docs[0].name.should.equal( + 'main.tex' + ) + expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist + }) + + it('should hide invites', function () { + expect(this.result.invites).to.have.length(0) + }) + + it('should have the correct features', function () { + expect(this.result.features.compileTimeout).to.equal(240) + }) }) describe('deletedByExternalDataSource', function () { @@ -213,8 +306,10 @@ describe('ProjectEditorHandler', function () { delete this.project.deletedByExternalDataSource const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(false) }) @@ -222,8 +317,10 @@ describe('ProjectEditorHandler', function () { it('should set the deletedByExternalDataSource flag to false when it is false', function () { const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(false) }) @@ -232,8 +329,10 @@ describe('ProjectEditorHandler', function () { this.project.deletedByExternalDataSource = true const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(true) }) @@ -249,8 +348,10 @@ describe('ProjectEditorHandler', function () { } this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) @@ -278,8 +379,10 @@ describe('ProjectEditorHandler', function () { } this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) it('should not emit trackChangesState', function () { @@ -302,8 +405,10 @@ describe('ProjectEditorHandler', function () { this.project.track_changes = dbEntry this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) it(`should set trackChangesState=${expected}`, function () { @@ -322,66 +427,4 @@ describe('ProjectEditorHandler', function () { }) }) }) - - describe('buildOwnerAndMembersViews', function () { - beforeEach(function () { - this.owner.features = { - versioning: true, - collaborators: 3, - compileGroup: 'priority', - compileTimeout: 22, - } - this.result = this.handler.buildOwnerAndMembersViews(this.members) - }) - - it('should produce an object with the right keys', function () { - expect(this.result).to.have.all.keys([ - 'owner', - 'ownerFeatures', - 'members', - ]) - }) - - it('should separate the owner from the members', function () { - this.result.members.length.should.equal(this.members.length - 1) - expect(this.result.owner._id).to.equal(this.owner._id) - expect(this.result.owner.email).to.equal(this.owner.email) - expect( - this.result.members.filter(m => m._id === this.owner._id).length - ).to.equal(0) - }) - - it('should extract the ownerFeatures from the owner object', function () { - expect(this.result.ownerFeatures).to.deep.equal(this.owner.features) - }) - - describe('when there is no owner', function () { - beforeEach(function () { - // remove the owner from members list - this.membersWithoutOwner = this.members.filter( - m => m.user._id !== this.owner._id - ) - this.result = this.handler.buildOwnerAndMembersViews( - this.membersWithoutOwner - ) - }) - - it('should produce an object with the right keys', function () { - expect(this.result).to.have.all.keys([ - 'owner', - 'ownerFeatures', - 'members', - ]) - }) - - it('should not separate out an owner', function () { - this.result.members.length.should.equal(this.membersWithoutOwner.length) - expect(this.result.owner).to.equal(null) - }) - - it('should not extract the ownerFeatures from the owner object', function () { - expect(this.result.ownerFeatures).to.equal(null) - }) - }) - }) }) From 3fbbb50ef751a90ddd90b326f5065437790dbc04 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:38:47 +0200 Subject: [PATCH 011/209] [web] use correct term in setPublicAccessLevel API wrapper (#25848) GitOrigin-RevId: 022c59d6d5c6f239438ed8e91f3ca47954198a0c --- .../features/share-project-modal/components/link-sharing.tsx | 4 ++-- .../web/frontend/js/features/share-project-modal/utils/api.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index d235bd248b..a2d17734b0 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useShareProjectContext } from './share-project-modal' -import { setProjectAccessLevel } from '../utils/api' +import { setPublicAccessLevel } from '../utils/api' import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' import { useProjectContext } from '@/shared/context/project-context' import * as eventTracking from '../../../infrastructure/event-tracking' @@ -43,7 +43,7 @@ export default function LinkSharing() { project_id: projectId, }) monitorRequest(() => - setProjectAccessLevel(projectId, newPublicAccessLevel) + setPublicAccessLevel(projectId, newPublicAccessLevel) ) .then(() => { // NOTE: not calling `updateProject` here as it receives data via diff --git a/services/web/frontend/js/features/share-project-modal/utils/api.js b/services/web/frontend/js/features/share-project-modal/utils/api.js index d52b6a4857..38b2040f2b 100644 --- a/services/web/frontend/js/features/share-project-modal/utils/api.js +++ b/services/web/frontend/js/features/share-project-modal/utils/api.js @@ -47,7 +47,7 @@ export function transferProjectOwnership(projectId, member) { }) } -export function setProjectAccessLevel(projectId, publicAccessLevel) { +export function setPublicAccessLevel(projectId, publicAccessLevel) { return postJSON(`/project/${projectId}/settings/admin`, { body: { publicAccessLevel }, }) From 35500cc72bdd29f700e8a9ef6b8a7df9801a8754 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Mon, 2 Jun 2025 06:18:06 -0700 Subject: [PATCH 012/209] Merge pull request #25607 from overleaf/mf-free-trial-limit-stripe-handler [web] Limit user free trial on stripe subscription GitOrigin-RevId: b3d978ed598d20451a99cf811fcae9ba2e3b23f0 --- services/web/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9e0f86e8b9..445fb62c8b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2739,6 +2739,7 @@ "youre_signed_up": "You’re signed up", "youve_added_more_licenses": "You’ve added more license(s)!", "youve_added_x_more_licenses_to_your_subscription_invite_people": "You’ve added __users__ more license(s) to your subscription. <0>Invite people.", + "youve_already_used_your_free_tial": "You’ve already used your free trial. Upgrade to continue using premium features.", "youve_lost_collaboration_access": "You’ve lost collaboration access", "youve_paused_your_subscription": "Your <0>__planName__ subscription is paused until <0>__reactivationDate__, then it’ll automatically unpause. You can unpause early at any time.", "youve_unlinked_all_users": "You’ve unlinked all users", From 4b9963757f9df83f859a80d82e6a5b69785163d0 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 2 Jun 2025 14:47:24 +0100 Subject: [PATCH 013/209] Merge pull request #26047 from overleaf/bg-web-api-is-leaking-disk-space clean up temporary files in GitBridgeHandler operations GitOrigin-RevId: b4a202f4f4c563a020fed8a47da1a84417ccbd2d --- .../acceptance/src/mocks/MockGitBridgeApi.mjs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs index 4927814b9a..805bbdd8fe 100644 --- a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs @@ -3,12 +3,16 @@ import AbstractMockApi from './AbstractMockApi.mjs' class MockGitBridgeApi extends AbstractMockApi { reset() { this.projects = {} + this.postbacks = {} } applyRoutes() { this.app.delete('/api/projects/:projectId', (req, res) => { this.deleteProject(req, res) }) + this.app.post('/postback/:id', (req, res) => { + this.postback(req, res) + }) } deleteProject(req, res) { @@ -16,6 +20,24 @@ class MockGitBridgeApi extends AbstractMockApi { delete this.projects[projectId] res.sendStatus(204) } + + // Git bridge accepts a postback to indicate when a operation is complete. + // Each postback is identified by a unique ID. + // Allow registering a handler which resolves when a postback is received. + registerPostback(id) { + return new Promise((resolve, reject) => { + this.postbacks[id] = { resolve, reject } + }) + } + + postback(req, res) { + const { id } = req.params + const postbackData = req.body + if (this.postbacks[id]) { + this.postbacks[id].resolve(postbackData) + } + res.sendStatus(204) + } } export default MockGitBridgeApi From 3a96df4623b82d94a956693c26394c321be84c09 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 2 Jun 2025 14:50:28 +0100 Subject: [PATCH 014/209] Merge pull request #26050 from overleaf/em-saml-user-query Improve index usage for SAML user query GitOrigin-RevId: 189aba60a12c8369a0062e7df4c57bef8a16c98c --- .../web/app/src/Features/User/SAMLIdentityManager.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/User/SAMLIdentityManager.js b/services/web/app/src/Features/User/SAMLIdentityManager.js index dc790c59ca..0d3c382775 100644 --- a/services/web/app/src/Features/User/SAMLIdentityManager.js +++ b/services/web/app/src/Features/User/SAMLIdentityManager.js @@ -210,9 +210,13 @@ async function getUser(providerId, externalUserId, userIdAttribute) { ) } const user = await User.findOne({ - 'samlIdentifiers.externalUserId': externalUserId.toString(), - 'samlIdentifiers.providerId': providerId.toString(), - 'samlIdentifiers.userIdAttribute': userIdAttribute.toString(), + samlIdentifiers: { + $elemMatch: { + externalUserId: externalUserId.toString(), + providerId: providerId.toString(), + userIdAttribute: userIdAttribute.toString(), + }, + }, }).exec() return user From 48337b2e2ca84da0cdf675ada0e8e945fbe64a10 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 15:27:18 +0100 Subject: [PATCH 015/209] Merge pull request #25808 from overleaf/mj-ide-full-project-search [web] Editor redesign: Add full project search GitOrigin-RevId: b4327c4ba0ddd7387ec8d6640e31200ca0fe4a6e --- services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 1 + ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 4384 -> 4444 bytes .../material-symbols/unfilled-symbols.mjs | 3 ++- .../features/event-tracking/search-events.ts | 2 +- .../components/full-project-search-panel.tsx | 19 +++++++++++++++ .../features/ide-redesign/components/rail.tsx | 23 ++++++++++++++++-- .../ide-redesign/contexts/rail-context.tsx | 1 + .../components/codemirror-search-form.tsx | 5 +--- .../components/full-project-search-button.tsx | 12 +++++++-- services/web/locales/en.json | 1 + 11 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a7ff970ef0..d8892e70ff 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -996,6 +996,7 @@ module.exports = { toastGenerators: [], editorSidebarComponents: [], fileTreeToolbarComponents: [], + fullProjectSearchPanel: [], integrationPanelComponents: [], referenceSearchSetting: [], }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 09c2ba90dc..20459e0ed6 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1292,6 +1292,7 @@ "project_ownership_transfer_confirmation_2": "", "project_renamed_or_deleted": "", "project_renamed_or_deleted_detail": "", + "project_search": "", "project_search_file_count": "", "project_search_file_count_plural": "", "project_search_result_count": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index df942df17688781db3c188df5a20253919ec02fc..8e72799b077bd442cf7211bd1488813826bc9018 100644 GIT binary patch literal 4444 zcmV-i5u@&RPew8T0RR9101;dO4gdfE03%=k01*TL0RR9100000000000000000000 z0000ShZ+W8KT}jeRAK;vOc4kQu|%nJ3uFKRHUcCAWCS1ug$@TG3QGalnu!{m^E0t9 z^L(4QATz$G8i1$&7tDQh1~3zoiJj7cO*_KbH`)Jx?$zf14v>nj!j0sNnQ_g1FKJ)y zPa(xni84%nfs;QVcAHzL2~d&S@dRoXNKe=qGbyLUjx zXU-bbz5~b3QGRgs#UsiXCgP}Id-mY&ZTu@)AIRVVO8$~hTsZiMe8QI<5{t_>ZgGSa zv@VW0GhZwdH&?Ju|>Mj^#spAkf ziLE+C7IvfB^@u>(Y01-Nfum1|4Oc@f!IgG!CZxnjzx9 zAbEaqjBV6WRB`d3;fNU#9>wd_E3ssx0+6W7^cs#O&YUsFaEN8J$>ZYK$k`Zho1 zA#)0b=eU$ILt?J((v<51LWk3!0Ev~cU}$;KpFqloU(wgYCNs5Wqg(=ufxs|?or z3<|~`6JYcoS(=_r3@0yI6!gd0t2r*|;K`E*`9tvUy9xe(1YhD{$q3D`&4RzqaJsJ=Z?JzUTU->%ZPG zZ!W*}(5<&Ck5!(kyi@tGvaYhXa-?#s^5312cmDms`EluQ)BZeCts;baqY0=5tw0Z< zC$FF@4_rBWb@{a=C}aKPGgAGt`cL)i>apt4>Jfx`ZSS?TXQ9XW9vgbh>wbUth2490 z`?c%7E~~qIWN$~v_U-5F$L*E&e7nWQ)^}EoendNhzt1eTbNptUr=$7rn_*9*Gy+8I z+rL^x8jb#7p<%P{J)&=eD&6GNX)F$R^HL$pWG1$$1pURvRZKffN zoL^WjIRNFyk-w{d;arzb9%QfGYnKle+L?pH)n>HQPRhnT#M)qo<;80N*jk$=I|Aj4IV8qF5tapL~(o0Tvt4h$#sO}CYo$SgWN z^v#IdG+~w(2S(_dcLzu#vi2*&zvx0drKQ$F4LsC9o|%5s___rckSEp?xHlo!RxK-^ z4c-Nt+W<UqBpQJ|v2GVjIPa-%l|dzgUIb zHe04&U`vfyb2U*Fq{NrzRML35nCx{&!HJ~0MD*1zNdAMI-+hE9Hci$7TVu_Pci>=~ z;e=sz3B^3j1PADlAr2h^Gr?6#mpD%0#4zD)2yuK8=!d%Z|B`=fs3!+!02F?9-rQ*< zF!toE@LaPtD0%NTvJ-~1g1N)M6e`r_2H{me6~5MA(K(z~xH=zv3n_MSQvT={Nbq;{ zUmp5pDp5G?7SL;2(wFAciv7b72}xqt_mRYgVKzUGfmv;z+>CrkB;WQxv@P~u$ zuwrJ@pnVhtJmLqGK6he_+}*8srQ%^ns~53A#(^p{Xdk)r>{y=d@$Uwh_g!a15%um% zxH%*8jmJkSep)LZlq_$@iGjN8UcA;`7Oxq3w59Qy(I*b9ohJv;JsCBS6*j=>_ z`RltlLuj#W0v$Xh8m9qFg^Oq6emw9)<6Y~oi>PbOqhGMrAMkKcrovB2K6e-X&6skaS zVmc8bHJKd}hZ0U3r00Tyty{sREe1Hi}ZG~o7nOE-#Hk(g-P z1Rubr(gNLp#WFDc)X`eD$8Hw`MUfWmHan}0Vk=ChifAwJIqyM>v&i;7WEy;slFJA_Lp!b6) zhB|0EcQB32T9e&*k-zIKc^sE#(dqNd9B0YX>CMR@X1y-Y!kM>b7oRw>c)rAj@$S(Yi$t$rqRLtfD}^JjNEQAd=77msab`E!G#44At1W zZJQ{q&pDsP&}70ztmfHs8ns$2Kf~vok5Lx9z^FCO@Y81Uj9%Od%7X8`*u7WYXMNEP zLA(tF>obaepuS6SP2PbrG8H69SRHh&H2%x zq0#f-d^9Q6!xx2pnkvdfGE4tA>SZFFjVa$hZwj3)N%D`!>kh&Ko`?k;Knmi94IkBp z1$-cFX=wW0yq$xgBRf5s|G{?Ruq#3I{G>0Uxdcs4{_lrE?RxlrFSL*E~d@B?Ym%I$M}} z+L{jISH)I?0RYZwah*&sLZ-24Nq7t9C;a?Wz3(amfU}++2m=6@TaFXTe&e!F65Jk@>?-kjP2`WYz`5|o z`+N5Eu4)%9FtsXGEdw;UEPjvFfA|pKMvJx+T~2TlmeKP#?Yf!!tq2CB?}}#U)8at!;&+VG!mmdV7oBzMJ2l^wZCW z1_uiZUC}O2SCmV!0XElp%HTOAiLB!eBvt!=^3}Yk@_D^y_l)$@X#7U*Ioq3jyM6UZ zwSUq9j;u?X;;GO1t<7P>z4}^9BIX)`=$caffRE3BzNVBm1kEjpX!Yt#*={(K-_(@9 z-$;4<4`K43*5UQ}2iKR>sA^U2H(K*nHatY>xAfI;rKex;3q(t3OptOlHA@OVj7tde z@J#TFJLHz*qP?8mqFv&a@yF4`6|08Sh;Dg$L{~$~id7=peyYMLG37|G{Mj(ibR$7# z?%!mb6XIPMJoubVd~@jcmTAzf9ebJhv2?I)`3)8Ze< z-Vb-lxRR}J)cM%pz}#ES{qo-9i>sDgw?UNC^QI6bxtW7mhLpiNuwc2mb9EfY)t#&3 zAWHJ}h_%62f4yD2r!>q)GY9oug6c^gxl;9y$K^ElE1roo3t_kP*B{lx9RZ0+s-vUBT+TYuTviR!widGy5SJuO{5cQ=on z7`vwlqNL@Ne>%?IC5C04W8&@ePdiBlNm6i|Q$j+AW1s|4I*~c1C@dKkgged&clajy z1qLR0``nQ&d}N$`{96}%di2&mlPb04+J1ZN!PcRxvuUAI57T@y_l^2fN=u&ii@iDP zt)F*Q$=d1K0aI=Vo1?ZK7o#jnuU4E6dGgxhsf8Ku$#JXD&FAo)0zNz%-fvbK9Z(zB zC~xe%S*$F-Ro&j0+8F0wTN>SGhMVb|Zzy2@@=?-$O0T~Zx8H#G=*Moz944pf=}9to zj+~?W`pJyPZpfI`yN7;eMh~*v>-2fYo{cj7Y|lBtQ+vH^fOFKoqYk!mK99${M}tSt zR@-tR_H;6R?e2^VW%{lz6-Nc*f9(>U53LXAG7PX(*cv7t#41kz6ll3*9Thr!q1-Jj z*(WF=t=V<*3;+NCKmZ_L4}IaKFC4OudH<$MQV#;ar$Ij~0l=rhq?6_s%{KK6^%?+e zkN^Mz_}h0K>Osvfn%}5r{_5=^bqVT0Jc$P}#=`_06Zjw`3D0#Ogc_X?P^+kl@TanP z_OYrOxiN-3AI}5!Pyjd&8SD^lLJ=H^wZHL>iO`N<r7DV(1=Dz>A0vCvlVr z4yPc;*Wolctc7dgB6>z>BN%}a@w2h)1>pqr;Sf&ZJkEzxkU=(^j-Cfb7(fOf8fsKP zgaS&WARomjL=mbGgECa17`f7}3=)prfP93Z0t)0IAE79YqAL*v1*-H46c?+RfO?3? zftuE>L_E}}hN`fY2PZgSs@r9cIeRavF@*lMW2zzKHnfHT~Y=f;S+F(Qc( zqhnln?|K$th(Qd(z#1MRF^i0K20@K7)GJX3Q2EqOa0P=fQKm*Al;lJVA`yuo*nt6q i00Eey3e}+JqtXPdS)$#4YC`ln@5X*7q3OPn9tHq(rG04t literal 4384 zcmV+*5#R22Pew8T0RR9101+Sn4gdfE03zG~01(Ik0RR9100000000000000000000 z0000ShYAK@KT}jeRAK;vL=gxIuvn=Z3t|8PHUcCAVgw)sg$@TG3W^Q4{`v0*&wp#L_szlwpjp8DY*JuwfNVr`*{CYCs;931mouF&$uuKMA_xKB z%aUe+T_Di3ta;5zus{K(TgzVi6kz961poiI>0fA(CXisQ=T+R&rFCiw^-R?0IX@E% zGtZ2<=^y^R-=7Fxu`O}kh$XGPB%BBO3-(tKfL%F@06tVt;KbdRsz|H=c1e|VU&XwB z?$Z_k9Ui(9t|V(rjr-a6lHZrj`;f!X5vBOy0FxAmUF&+)M4(#N@dUyF>FO#`DXT<( z#$4Y+Mr&KY{1+MF6UigMabsN%0u=FCjoQ2a=vk`wuRMQ96_cUVD?~iKf9DqYBUv2G zBnK$@LosvlkVoVLdC8>DU%qgY!(?=GE~Y|(F#|IC#KeqAWNFKk2yqG62($@0!SS@U zGWrZ=6&XWj{NND@ias`7@(9OE(D+P=F{lwHI8?=v5o5*xmqh{%ODgi@u(=TuAYsh_ zCj3Jq#w?&2Gbcw|p(107C#YpUz(@wFII{C#d8%lJB%~5r$LNfZ5fkA9mghW??|;My zlg((_6h(!J&qNpvEE84ONdSRIBiBiN+JM>`pBqbIpXXo9wrBw z)1`=oxsFhMOz*}L=!QL0+84#J|o9V9&Ia-3ocYs#JF-Q^?Y|L#t|`|l6Vj}QK~*?!OmIVTe^NwJBUk}`yA|Fj`L!)pd;nbUJ&|H z8!8o~k+w5glW6=oLfK0Q;f;#Hth$v#QfjAA4yCiuccSEH=>!}+10F%acXTD4xFC1S zZa@-6Q5=m`73(;Yk#t?xRoRebS=|cscE-x;$ZXb-bZMxrTl#! z1BEg0_;wK4HXLk0g1?pU?I5ykIM{*&e`^g0i~+F01w5w)ry#{n6OErll7HewO~O=A zU7TM)9uvrDCq#?|LT^*?vr?7Y!LG0xOkDg!lFJA-_;4c)SFxh=T(1`)V13^_-9C%uUf|8N$fPG+xE+9X*fiNp2;XSBQ5!;jtKozjutz9( z*g<(%W89R}>MR002SmB{pLy(<>nsLjVpA^~-1qPJzfy6NR5df`0qDw>HjJQn`W_fX7gueN3 z(u8z;P576CNT9UW+NsJ@6%?rDM|GFmfeCryJdwFGysBufyl(I)*iZ-1B6T_h4z{%b z@_m4vRQ=W|1vP^J;7NX=1^)%aVU?4jh$pU5z4-A|!wHMk$ZfkheS+;Z;>QuiVL7;yD<4zN2+GVL3QJCrxtb zBv=lva(9{IY&q6VxGO?D9|ZGI_JQB>k4*OD;1qzuPfuDpi3G-8ToIpZR|VUGcRSe$ zLt4PRp=0ujwRu5&6;XwEgeyAR3kuuv!FP~i=NIG;e~AQt$JnMYEYpd?XtRJ;)1JOE zr%D_fhDb;fyQYmq*1C~zgu#l` zw7HXG$S%;wSDXIHwoyI3?P7sR0m zFO^CoNTE9Mo$^$9B85mPzCc_|TH)516(Pn&N{K?G z28Fc}WVQO@y?|`1Y)xcq)Cjyj9FH`p-Iga7R)y6>VD4gEg7?QgB)By;0CN{Nny!gF zN*Jx2Zn^>`W=qI-qf$&EwyHIe3Eaht`qxxe<0!bLv7*t}u3hul_AmrFh&>JC>eXmO z$!_XI(+U@B!Qt|*T)EQN1#d0KE8B6lyaBIw$2|?hTdBhuJTSL9yd7`HTd)?g4Qs<& z@z!#Y8c@KzdiJWwxPH0BmdYw>Ng&GJULVwn1K|hm54SW~n*Td?+|qb_YO|%OW__ap zVmZX42MUrVNt&FOvOz3EG4H?QD;^*o`RN}3fF1xIGb2TQd22aIPs~iZG8-Pi^8B82 zDoUVKXf>sxRwET91ie;uBk+Y{uwk>YuFzOiTlb-}N!_XgbqC-&r`j3u31&nxPsFrM z5SM2KXrt!`03hbRD@l{9s?usz1`)d;>kA^j7j-1EY+^@|KGDRo9f?JHQ+9|+UzFI< z%Cs#rX6x25lch$-er_hX(s~&LSIXinBBk$jyLDvsX}N;-FJ9wR(Oi5P3u0u{lL16I`%;It^_Zb=kfw^^Dv%S;E7wunQP_+ z;s$feY`!(ud~=v3>sEHeY|=ruTQ?mVhTX-c2=oPw4h|07K;3SgQ5HMW=wKXZ?~p4{ zdT?r}Bj3lQbEDp8BFhOe+;zkw>ng+d4)LGWcRS=u^8Ph?EC&kRp@wWf;&G^ zoh#x|FT^7!f){4ad{rY5k5)#Gi|y9ET3Pl5C&rPqY#NLsZ3nn&$4{U`uDQE5J6VwI zbTxPVjo)p2pL6K*KYc}cg%&8GzMkG9mv5m#6>wLr-WgH@aVz&D9$AWjMD593GT2`1 zm0pxU)m}~QB6uM;H%?f3%6uKVqxcGg0RVOxaqP(#T9Rwd9_v+u1@TIs31~;A$dA%T zkG3`1*z~G6Q(m?0H!9Y5jQJ5)c;^9WI* zUhIq`xgj30B_;F`mu27XX4 zmx8OoZ-B4A$0j>0)-KoczSFmHjtRX_%dy+o9016#=>hW*k76&a^aOyremQ{$^XBf= z-n{&L`r>rm9l{?QfwcJO{iRD=SB<@U>3o?ip9Y#;9=BB5#&Q_A&ISAGH=X$W!7obO z{!+a@ocMgmS0A!^AU$+;{=?&{>oU1f9#+zC;hQPA&~M4ZovvZ&X7l6=l_?pX z;Hed~T0v_!=YH=`pI5iGrlj2UyQ{wIdsi|OmN&TFpza$=6tF8pa~+?&8t$7lykXZ; zXM3g6-g)V+hU~lT)stMu(3LDv5ZYI*zwuie&v1&8Cj4MJJV(CT%uRA_v3&wAQX~71nnd&|_eaSW94DKu{iZU-PwgVRpCpRQkMV zM5m(6ZVRg#)?}|OF3pVd;1|IN+m6hH1^at=2YZIifWOL-v-UNy0WDa3KyyvZtbHV< zL8HVyGDeAuT^IPpnG{*VJoyF_mJ(19eW7U4&cxLJ@YUh$^UUB0N~IWz4e|5Ae@1ia`b`?8HKhc#qpZU zV#ggKyc*QXUE;z|C2a^jMMg>T=1mv6I>+ym9ax*{)SMmCq7dYM(+c1avv#!>K@r83 z0w_q8P8EV6RXSA&z#+B}FyD3S*FpSPp1^Ty926c&F1U<>G+6#k6_Z51sg}c-{|w2( zH~+%l{!5Z{`44cI=*E(yOGLvYXS$@x7I>z-{&V@D+QFa8>uWz(_p9yyxw;zEe2nZ< z>pQlsx%DX7uhxI87T{3IJwr3h#WRFL@dycb$?-?;a7+=*ETshiVD^DOtHa z$;pRwk`K`%8EkeGm3Am8`ME`s>L*X|N_qdks8VT6nz2sVT;A^GVU^|HYD%i7-L2Nh zw?k1z=-$NpKh_GOrQ@Yp!`_=qeNMjmLAH6uC7Qg;CKqF~M2|7BKe+`>MVperGi70i zbzU&5GK|28roDJ+@&3XFB9;haR^|n_TZ6rldv|40{?qa$8TlS!e;Ur7=B0e+bi+z< zqr7pwXxbufk(VqL>+hVd-#p@y{Akk%MxX5-nHREXpE|NBW=nI-(7o!i+>oK?x(TSw zGw1PQZ2Ib{9Rax+L2{)$?_&K7b!0?%>)2-fpTM%GnLQ~Bv?)G@0B!lPV#DN*sXjaW z3NlyDXo<|KOh_xM8HiXv1poj9KzyG0>eQ|CP2=Lf4i}~C0E21ocK{5gi=qEN|34@_ zlAZxW5dje3r|Ejq_5Yv$f0Z8nDD5J(1?f8Xxy~Bawi(w5ybi(Um-VlsFrQ|+^fV92 z3HPL}<1_^m$BdUJiY5pHFrCo^l5IgRf`x$29`B_-hJ8PShUF2&Y8F&oRu*LlZ_Z8Yo1e z6%Pj7RAIq^4Lj`=q0){6leD*nvg7w+A&XWFn6Z$FW02BK0S4NQRvc=z%jm&_5hbbL zMkxv%c$IB4p#)=U0hEGfql4Q9TB+1!ZQ@fac+n6_Akl;og*-2fn3qNbHDYuc2j2TW zjx35OB8xc7JuAi_XxHG6PIWhZ-;} awAp~2GYtG2#iPx+myzd%{|nE^D**u2R#deB diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index baefac05aa..1c41421910 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -10,6 +10,7 @@ export default /** @type {const} */ ([ 'create_new_folder', 'delete', 'description', + 'error', 'experiment', 'forum', 'help', @@ -20,10 +21,10 @@ export default /** @type {const} */ ([ 'picture_as_pdf', 'rate_review', 'report', + 'search', 'settings', 'space_dashboard', 'table_chart', 'upload_file', 'web_asset', - 'error', ]) diff --git a/services/web/frontend/js/features/event-tracking/search-events.ts b/services/web/frontend/js/features/event-tracking/search-events.ts index cd9ff4b8ba..630d07aeaa 100644 --- a/services/web/frontend/js/features/event-tracking/search-events.ts +++ b/services/web/frontend/js/features/event-tracking/search-events.ts @@ -6,7 +6,7 @@ type SearchEventSegmentation = { searchType: 'full-project' } & ( | { method: 'keyboard' } - | { method: 'button'; location: 'toolbar' | 'search-form' } + | { method: 'button'; location: 'toolbar' | 'search-form' | 'rail' } )) | ({ searchType: 'document' diff --git a/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx new file mode 100644 index 0000000000..926341ce89 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx @@ -0,0 +1,19 @@ +import { ElementType } from 'react' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' + +const componentModule = importOverleafModules('fullProjectSearchPanel')[0] as + | { + import: { default: ElementType } + path: string + } + | undefined + +export const FullProjectSearchPanel = () => { + if (!componentModule) { + return null + } + const FullProjectSearch = componentModule.import.default + return +} + +export const hasFullProjectSearch = Boolean(componentModule) 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 d6e1112536..9bd70ac4bb 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -34,6 +34,11 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLIconButton from '@/features/ui/components/ol/ol-icon-button' import { useChatContext } from '@/features/chat/context/chat-context' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { + FullProjectSearchPanel, + hasFullProjectSearch, +} from './full-project-search-panel' +import { sendSearchEvent } from '@/features/event-tracking/search-events' type RailElement = { icon: AvailableUnfilledIcon @@ -106,6 +111,13 @@ export const RailLayout = () => { title: t('file_tree'), component: , }, + { + key: 'full-project-search', + icon: 'search', + title: t('project_search'), + component: , + hide: !hasFullProjectSearch, + }, { key: 'integrations', icon: 'integration_instructions', @@ -170,10 +182,17 @@ export const RailLayout = () => { // Attempting to open a non-existent tab return } - const keyOrDefault = key ?? 'file-tree' + const keyOrDefault = (key ?? 'file-tree') as RailTabKey // Change the selected tab and make sure it's open - openTab(keyOrDefault as RailTabKey) + openTab(keyOrDefault) sendEvent('rail-click', { tab: keyOrDefault }) + if (keyOrDefault === 'full-project-search') { + sendSearchEvent('search-open', { + searchType: 'full-project', + method: 'button', + location: 'rail', + }) + } if (key === 'chat') { markMessagesAsRead() diff --git a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx index c02d17fb9b..85ec482fc0 100644 --- a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx +++ b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx @@ -19,6 +19,7 @@ export type RailTabKey = | 'review-panel' | 'chat' | 'errors' + | 'full-project-search' export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary' diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx index 90a968add6..a65232f94d 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx @@ -36,7 +36,6 @@ import { getStoredSelection, setStoredSelection } from '../extensions/search' import { debounce } from 'lodash' import { EditorSelection, EditorState } from '@codemirror/state' import { sendSearchEvent } from '@/features/event-tracking/search-events' -import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' import { FullProjectSearchButton } from './full-project-search-button' const MATCH_COUNT_DEBOUNCE_WAIT = 100 // the amount of ms to wait before counting matches @@ -82,8 +81,6 @@ const CodeMirrorSearchForm: FC = () => { const inputRef = useRef(null) const replaceRef = useRef(null) - const newEditor = useIsNewEditorEnabled() - const handleInputRef = useCallback((node: HTMLInputElement) => { inputRef.current = node @@ -443,7 +440,7 @@ const CodeMirrorSearchForm: FC = () => { - {!newEditor && } + {position !== null && (
diff --git a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx index 698204d89c..be02fdbe3c 100644 --- a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx +++ b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx @@ -12,6 +12,8 @@ import Close from '@/shared/components/close' import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { useEditorContext } from '@/shared/context/editor-context' import getMeta from '@/utils/meta' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' const PROMOTION_SIGNUP_CUT_OFF_DATE = new Date('2025-04-22T00:00:00Z') @@ -19,6 +21,8 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => { const view = useCodeMirrorViewContext() const { t } = useTranslation() const { setProjectSearchIsOpen } = useLayoutContext() + const newEditor = useIsNewEditorEnabled() + const { openTab } = useRailContext() const ref = useRef(null) const { inactiveTutorials } = useEditorContext() @@ -44,14 +48,18 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => { } const openFullProjectSearch = useCallback(() => { - setProjectSearchIsOpen(true) + if (newEditor) { + openTab('full-project-search') + } else { + setProjectSearchIsOpen(true) + } closeSearchPanel(view) window.setTimeout(() => { window.dispatchEvent( new CustomEvent('editor:full-project-search', { detail: query }) ) }, 200) - }, [setProjectSearchIsOpen, query, view]) + }, [setProjectSearchIsOpen, query, view, newEditor, openTab]) const onClick = useCallback(() => { sendSearchEvent('search-open', { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 445fb62c8b..2efd23fd9f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1708,6 +1708,7 @@ "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": "Project search", "project_search_file_count": "in __count__ file", "project_search_file_count_plural": "in __count__ files", "project_search_result_count": "__count__ result", From a63e25953ff7ee703f5f47cf5f58fc701b70ac0d Mon Sep 17 00:00:00 2001 From: roo hutton Date: Tue, 3 Jun 2025 10:02:22 +0100 Subject: [PATCH 016/209] Merge pull request #25896 from overleaf/rh-load-odc-data Load ODC data when revisiting onboarding form GitOrigin-RevId: 506df5d58a8b0305d83b9f43986a55fd309a2720 --- services/web/frontend/js/utils/meta.ts | 3 +++ services/web/types/onboarding.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 services/web/types/onboarding.ts diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 2a396c805b..6e15309187 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -34,6 +34,7 @@ import { import { SplitTestInfo } from '../../../types/split-test' import { ValidationStatus } from '../../../types/group-management/validation' import { ManagedInstitution } from '../../../types/subscription/dashboard/managed-institution' +import { OnboardingFormData } from '../../../types/onboarding' import { GroupSSOTestResult } from '../../../modules/group-settings/frontend/js/utils/types' import { AccessToken, @@ -53,6 +54,7 @@ import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-na import { FooterMetadata } from '@/features/ui/components/types/footer-metadata' import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log' import { ActiveExperiment } from './labs-utils' + export interface Meta { 'ol-ExposedSettings': ExposedSettings 'ol-addonPrices': Record< @@ -170,6 +172,7 @@ export interface Meta { 'ol-notifications': NotificationType[] 'ol-notificationsInstitution': InstitutionType[] 'ol-oauthProviders': OAuthProviders + 'ol-odcData': OnboardingFormData 'ol-odcRole': string 'ol-overallThemes': OverallThemeMeta[] 'ol-pages': number diff --git a/services/web/types/onboarding.ts b/services/web/types/onboarding.ts new file mode 100644 index 0000000000..11ae3e51d0 --- /dev/null +++ b/services/web/types/onboarding.ts @@ -0,0 +1,25 @@ +export type UsedLatex = 'never' | 'occasionally' | 'often' +export type Occupation = + | 'university' + | 'company' + | 'nonprofitngo' + | 'government' + | 'other' + +export type OnboardingFormData = { + firstName: string + lastName: string + primaryOccupation: Occupation | null + usedLatex: UsedLatex | null + companyDivisionDepartment: string + companyJobTitle: string + governmentJobTitle: string + institutionName: string + otherJobTitle: string + nonprofitDivisionDepartment: string + nonprofitJobTitle: string + role: string + subjectArea: string + updatedAt?: Date + shouldReceiveUpdates?: boolean +} From 4aaf411cd2d4d5d8aa299c1a6583207087b473b0 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 3 Jun 2025 11:20:18 +0200 Subject: [PATCH 017/209] [misc] improve logging in history system (#26086) * [project-history] tag all the errors * [history-v1] log warnings for unexpected cases GitOrigin-RevId: 3189fa487eee88985688ff990ec101daad0d13b1 --- .../api/controllers/project_import.js | 2 + .../history-v1/api/controllers/projects.js | 11 ++++- services/history-v1/app.js | 2 + .../app/js/HistoryStoreManager.js | 41 +++++++++++++------ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index edffb19a25..72df912a88 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -35,6 +35,7 @@ async function importSnapshot(req, res) { try { snapshot = Snapshot.fromRaw(rawSnapshot) } catch (err) { + logger.warn({ err, projectId }, 'failed to import snapshot') return render.unprocessableEntity(res) } @@ -43,6 +44,7 @@ async function importSnapshot(req, res) { historyId = await chunkStore.initializeProject(projectId, snapshot) } catch (err) { if (err instanceof chunkStore.AlreadyInitialized) { + logger.warn({ err, projectId }, 'already initialized') return render.conflict(res) } else { throw err diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js index 47a1d959ad..031833688c 100644 --- a/services/history-v1/api/controllers/projects.js +++ b/services/history-v1/api/controllers/projects.js @@ -34,6 +34,7 @@ async function initializeProject(req, res, next) { res.status(HTTPStatus.OK).json({ projectId }) } catch (err) { if (err instanceof chunkStore.AlreadyInitialized) { + logger.warn({ err, projectId }, 'failed to initialize') render.conflict(res) } else { throw err @@ -242,11 +243,15 @@ async function createProjectBlob(req, res, next) { const sizeLimit = new StreamSizeLimit(maxUploadSize) await pipeline(req, sizeLimit, fs.createWriteStream(tmpPath)) if (sizeLimit.sizeLimitExceeded) { + logger.warn( + { projectId, expectedHash, maxUploadSize }, + 'blob exceeds size threshold' + ) return render.requestEntityTooLarge(res) } const hash = await blobHash.fromFile(tmpPath) if (hash !== expectedHash) { - logger.debug({ hash, expectedHash }, 'Hash mismatch') + logger.warn({ projectId, hash, expectedHash }, 'Hash mismatch') return render.conflict(res, 'File hash mismatch') } @@ -343,6 +348,10 @@ async function copyProjectBlob(req, res, next) { targetBlobStore.getBlob(blobHash), ]) if (!sourceBlob) { + logger.warn( + { sourceProjectId, targetProjectId, blobHash }, + 'missing source blob when copying across projects' + ) return render.notFound(res) } // Exit early if the blob exists in the target project. diff --git a/services/history-v1/app.js b/services/history-v1/app.js index 261f1001b6..dd991c1a6d 100644 --- a/services/history-v1/app.js +++ b/services/history-v1/app.js @@ -100,11 +100,13 @@ function setupErrorHandling() { }) } if (err.code === 'ENUM_MISMATCH') { + logger.warn({ err, projectId }, err.message) return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ message: 'invalid enum value: ' + err.paramName, }) } if (err.code === 'REQUIRED') { + logger.warn({ err, projectId }, err.message) return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ message: err.message, }) diff --git a/services/project-history/app/js/HistoryStoreManager.js b/services/project-history/app/js/HistoryStoreManager.js index bb41dfb3c0..38658bdf5b 100644 --- a/services/project-history/app/js/HistoryStoreManager.js +++ b/services/project-history/app/js/HistoryStoreManager.js @@ -35,7 +35,10 @@ class StringStream extends stream.Readable { _mocks.getMostRecentChunk = (projectId, historyId, callback) => { const path = `projects/${historyId}/latest/history` logger.debug({ projectId, historyId }, 'getting chunk from history service') - _requestChunk({ path, json: true }, callback) + _requestChunk({ path, json: true }, (err, chunk) => { + if (err) return callback(OError.tag(err)) + callback(null, chunk) + }) } /** @@ -54,7 +57,10 @@ export function getChunkAtVersion(projectId, historyId, version, callback) { { projectId, historyId, version }, 'getting chunk from history service for version' ) - _requestChunk({ path, json: true }, callback) + _requestChunk({ path, json: true }, (err, chunk) => { + if (err) return callback(OError.tag(err)) + callback(null, chunk) + }) } export function getMostRecentVersion(projectId, historyId, callback) { @@ -68,8 +74,10 @@ export function getMostRecentVersion(projectId, historyId, callback) { _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp) ) // find the latest project and doc versions in the chunk - _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => + _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => { + if (err1) err1 = OError.tag(err1) _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => { + if (err2) err2 = OError.tag(err2) // return the project and doc versions const projectStructureAndDocVersions = { project: projectVersion, @@ -83,7 +91,7 @@ export function getMostRecentVersion(projectId, historyId, callback) { chunk ) }) - ) + }) }) } @@ -211,7 +219,10 @@ export function getProjectBlob(historyId, blobHash, callback) { logger.debug({ historyId, blobHash }, 'getting blob from history service') _requestHistoryService( { path: `projects/${historyId}/blobs/${blobHash}` }, - callback + (err, blob) => { + if (err) return callback(OError.tag(err)) + callback(null, blob) + } ) } @@ -277,7 +288,10 @@ function createBlobFromString(historyId, data, fileId, callback) { (fsPath, cb) => { _createBlob(historyId, fsPath, cb) }, - callback + (err, hash) => { + if (err) return callback(OError.tag(err)) + callback(null, hash) + } ) } @@ -330,7 +344,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { try { ranges = HistoryBlobTranslator.createRangeBlobDataFromUpdate(update) } catch (error) { - return callback(error) + return callback(OError.tag(error)) } createBlobFromString( historyId, @@ -338,7 +352,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { `project-${projectId}-doc-${update.doc}`, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } if (ranges) { createBlobFromString( @@ -347,7 +361,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { `project-${projectId}-doc-${update.doc}-ranges`, (err, rangesHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } logger.debug( { fileHash, rangesHash }, @@ -415,7 +429,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { }, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } if (update.hash && update.hash !== fileHash) { logger.warn( @@ -447,7 +461,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { }, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } logger.debug({ fileHash }, 'created empty blob for file') callback(null, { file: fileHash }) @@ -520,7 +534,10 @@ export function initializeProject(historyId, callback) { export function deleteProject(projectId, callback) { _requestHistoryService( { method: 'DELETE', path: `projects/${projectId}` }, - callback + err => { + if (err) return callback(OError.tag(err)) + callback(null) + } ) } From ee23e8f49f45cf60ab2a5e6bffa28e957a5ab5c9 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 3 Jun 2025 12:27:23 +0200 Subject: [PATCH 018/209] Merge pull request #26093 from overleaf/msm-e2e-fix [CE/SP] Force build of docker compose containers GitOrigin-RevId: 0605fcdcaf670e3d8435f1e180d2bfc34a29ed81 --- server-ce/test/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/server-ce/test/Makefile b/server-ce/test/Makefile index 18f4446902..48c3dfc475 100644 --- a/server-ce/test/Makefile +++ b/server-ce/test/Makefile @@ -20,6 +20,7 @@ test-e2e-native: npm run cypress:open test-e2e: + docker compose build host-admin docker compose up --no-log-prefix --exit-code-from=e2e e2e test-e2e-open: From b2b676249d58381a167273860dfe53c47790af78 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:49:38 +0100 Subject: [PATCH 019/209] Merge pull request #25928 from overleaf/bg-history-redis-move-test-script-helpers move test script helpers in history-v1 GitOrigin-RevId: cc2e5d8b1baea7396f948883a12a91846f77836c --- .../js/storage/expire_redis_chunks.test.js | 86 +------------------ .../acceptance/js/storage/support/redis.js | 75 ++++++++++++++++ .../js/storage/support/runscript.js | 35 ++++++++ 3 files changed, 114 insertions(+), 82 deletions(-) create mode 100644 services/history-v1/test/acceptance/js/storage/support/redis.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/runscript.js diff --git a/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js index b657991dda..f8a5943c43 100644 --- a/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js +++ b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js @@ -1,91 +1,13 @@ 'use strict' const { expect } = require('chai') -const { promisify } = require('node:util') -const { execFile } = require('node:child_process') -const { Snapshot, Author, Change } = require('overleaf-editor-core') +const { Author, Change } = require('overleaf-editor-core') const cleanup = require('./support/cleanup') -const redisBackend = require('../../../../storage/lib/chunk_store/redis') -const redis = require('../../../../storage/lib/redis') -const rclient = redis.rclientHistory -const keySchema = redisBackend.keySchema +const { setupProjectState, rclient, keySchema } = require('./support/redis') +const { runScript } = require('./support/runscript') const SCRIPT_PATH = 'storage/scripts/expire_redis_chunks.js' -async function runExpireScript() { - const TIMEOUT = 10 * 1000 // 10 seconds - let result - try { - result = await promisify(execFile)('node', [SCRIPT_PATH], { - encoding: 'utf-8', - timeout: TIMEOUT, - env: { - ...process.env, - LOG_LEVEL: 'debug', // Override LOG_LEVEL for script output - }, - }) - result.status = 0 - } catch (err) { - const { stdout, stderr, code } = err - if (typeof code !== 'number') { - console.error('Error running expire script:', err) - throw err - } - result = { stdout, stderr, status: code } - } - // The script might exit with status 1 if it finds no keys to process, which is ok - if (result.status !== 0 && result.status !== 1) { - console.error('Expire script failed:', result.stderr) - throw new Error(`expire script failed with status ${result.status}`) - } - return result -} - -// Helper to set up a basic project state in Redis -async function setupProjectState( - projectId, - { - headVersion = 0, - persistedVersion = null, - expireTime = null, - persistTime = null, - changes = [], - } -) { - const headSnapshot = new Snapshot() - await rclient.set( - keySchema.head({ projectId }), - JSON.stringify(headSnapshot.toRaw()) - ) - await rclient.set( - keySchema.headVersion({ projectId }), - headVersion.toString() - ) - - if (persistedVersion !== null) { - await rclient.set( - keySchema.persistedVersion({ projectId }), - persistedVersion.toString() - ) - } - if (expireTime !== null) { - await rclient.set( - keySchema.expireTime({ projectId }), - expireTime.toString() - ) - } - if (persistTime !== null) { - await rclient.set( - keySchema.persistTime({ projectId }), - persistTime.toString() - ) - } - if (changes.length > 0) { - const rawChanges = changes.map(c => JSON.stringify(c.toRaw())) - await rclient.rpush(keySchema.changes({ projectId }), ...rawChanges) - } -} - function makeChange() { const timestamp = new Date() const author = new Author(123, 'test@example.com', 'Test User') @@ -150,7 +72,7 @@ describe('expire_redis_chunks script', function () { }) // Run the expire script once after all projects are set up - await runExpireScript() + await runScript(SCRIPT_PATH) }) async function checkProjectStatus(projectId) { diff --git a/services/history-v1/test/acceptance/js/storage/support/redis.js b/services/history-v1/test/acceptance/js/storage/support/redis.js new file mode 100644 index 0000000000..3f5b9cda27 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/redis.js @@ -0,0 +1,75 @@ +'use strict' + +const { Snapshot } = require('overleaf-editor-core') +const redis = require('../../../../../storage/lib/redis') +const redisBackend = require('../../../../../storage/lib/chunk_store/redis') +const rclient = redis.rclientHistory +const keySchema = redisBackend.keySchema + +// Helper to set up a basic project state in Redis +async function setupProjectState( + projectId, + { + headVersion = 0, + persistedVersion = null, + expireTime = null, + persistTime = null, + changes = [], + expireTimeFuture = false, // Default to not setting future expire time unless specified + } +) { + const headSnapshot = new Snapshot() + await rclient.set( + keySchema.head({ projectId }), + JSON.stringify(headSnapshot.toRaw()) + ) + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) + + if (persistedVersion !== null) { + await rclient.set( + keySchema.persistedVersion({ projectId }), + persistedVersion.toString() + ) + } else { + await rclient.del(keySchema.persistedVersion({ projectId })) + } + + if (expireTime !== null) { + await rclient.set( + keySchema.expireTime({ projectId }), + expireTime.toString() + ) + } else { + // If expireTimeFuture is true, set it to a future time, otherwise delete it if null + if (expireTimeFuture) { + const futureExpireTime = Date.now() + 5 * 60 * 1000 // 5 minutes in the future + await rclient.set( + keySchema.expireTime({ projectId }), + futureExpireTime.toString() + ) + } else { + await rclient.del(keySchema.expireTime({ projectId })) + } + } + + if (persistTime !== null) { + await rclient.set( + keySchema.persistTime({ projectId }), + persistTime.toString() + ) + } else { + await rclient.del(keySchema.persistTime({ projectId })) + } + + if (changes.length > 0) { + const rawChanges = changes.map(c => JSON.stringify(c.toRaw())) + await rclient.rpush(keySchema.changes({ projectId }), ...rawChanges) + } else { + await rclient.del(keySchema.changes({ projectId })) + } +} + +module.exports = { setupProjectState, rclient, keySchema } diff --git a/services/history-v1/test/acceptance/js/storage/support/runscript.js b/services/history-v1/test/acceptance/js/storage/support/runscript.js new file mode 100644 index 0000000000..7ff8355566 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/runscript.js @@ -0,0 +1,35 @@ +'use strict' + +const { promisify } = require('node:util') +const { execFile } = require('node:child_process') + +async function runScript(scriptPath, options = {}) { + const TIMEOUT = options.timeout || 10 * 1000 // 10 seconds default + let result + try { + result = await promisify(execFile)('node', [scriptPath], { + encoding: 'utf-8', + timeout: TIMEOUT, + env: { + ...process.env, + LOG_LEVEL: 'debug', // Override LOG_LEVEL for script output + }, + }) + result.status = 0 + } catch (err) { + const { stdout, stderr, code } = err + if (typeof code !== 'number') { + console.error(`Error running script ${scriptPath}:`, err) + throw err + } + result = { stdout, stderr, status: code } + } + // The script might exit with status 1 if it finds no keys to process, which is ok + if (result.status !== 0 && result.status !== 1) { + console.error(`Script ${scriptPath} failed:`, result.stderr) + throw new Error(`Script ${scriptPath} failed with status ${result.status}`) + } + return result +} + +module.exports = { runScript } From cb350ecc657201aa05c6c0f3a0d0089208f3f611 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:49:52 +0100 Subject: [PATCH 020/209] Merge pull request #25907 from overleaf/bg-history-redis-persist-buffer add a `persistBuffer` method to history-v1 GitOrigin-RevId: 71a34e48e9ebe378e2f765f3216023e505a58a5d --- .../api/controllers/project_import.js | 4 +- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/persist_buffer.js | 173 +++++++++ .../history-v1/storage/lib/persist_changes.js | 23 +- .../js/storage/persist_buffer.test.mjs | 338 ++++++++++++++++++ 5 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 services/history-v1/storage/lib/persist_buffer.js create mode 100644 services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index 72df912a88..dee45efce8 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -110,7 +110,9 @@ async function importChanges(req, res, next) { let result try { - result = await persistChanges(projectId, changes, limits, endVersion) + result = await persistChanges(projectId, changes, limits, endVersion, { + queueChangesInRedis: true, + }) } catch (err) { if ( err instanceof Chunk.ConflictingEndVersion || diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 2aa492f46e..a9d8e2fc03 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -8,6 +8,7 @@ exports.mongodb = require('./lib/mongodb') exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') +exports.persistBuffer = require('./lib/persist_buffer').persistBuffer exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js new file mode 100644 index 0000000000..0dfeb9a38c --- /dev/null +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -0,0 +1,173 @@ +// @ts-check +'use strict' + +const logger = require('@overleaf/logger') +const OError = require('@overleaf/o-error') +const assert = require('./assert') +const chunkStore = require('./chunk_store') +const { BlobStore } = require('./blob_store') +const BatchBlobStore = require('./batch_blob_store') +const persistChanges = require('./persist_changes') +const redisBackend = require('./chunk_store/redis') + +/** + * Persist the changes from Redis buffer to the main storage + * + * Algorithm Outline: + * 1. Get the latest chunk's endVersion from the database + * 2. Get non-persisted changes from Redis that are after this endVersion. + * 3. If no such changes, exit. + * 4. Load file blobs for these Redis changes. + * 5. Run the persistChanges() algorithm to store these changes into a new chunk(s) in GCS. + * - This must not decrease the endVersion. If changes were processed, it must advance. + * 6. Set the new persisted version (endVersion of the latest persisted chunk) in Redis. + * + * @param {string} projectId + * @throws {Error | OError} If a critical error occurs during persistence. + */ +async function persistBuffer(projectId) { + assert.projectId(projectId) + logger.debug({ projectId }, 'starting persistBuffer operation') + + // Set limits to force us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + maxChanges: 0, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + + // 1. Get the latest chunk's endVersion from GCS/main store + let endVersion + const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId) + + if (latestChunkMetadata) { + endVersion = latestChunkMetadata.endVersion + } else { + endVersion = 0 // No chunks found, start from version 0 + logger.debug({ projectId }, 'no existing chunks found in main storage') + } + + logger.debug({ projectId, endVersion }, 'got latest persisted chunk') + + // 2. Get non-persisted changes from Redis + const changesToPersist = await redisBackend.getNonPersistedChanges( + projectId, + endVersion + ) + + if (changesToPersist.length === 0) { + logger.debug( + { projectId, endVersion }, + 'no new changes in Redis buffer to persist' + ) + // No changes to persist, update the persisted version in Redis + // to match the current endVersion. This shouldn't be needed + // unless a worker failed to update the persisted version. + await redisBackend.setPersistedVersion(projectId, endVersion) + return + } + + logger.debug( + { + projectId, + endVersion, + count: changesToPersist.length, + }, + 'found changes in Redis to persist' + ) + + // 4. Load file blobs for these Redis changes. Errors will propagate. + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + + const blobHashes = new Set() + for (const change of changesToPersist) { + change.findBlobHashes(blobHashes) + } + if (blobHashes.size > 0) { + await batchBlobStore.preload(Array.from(blobHashes)) + } + for (const change of changesToPersist) { + await change.loadFiles('lazy', blobStore) + } + + // 5. Run the persistChanges() algorithm. Errors will propagate. + logger.debug( + { + projectId, + endVersion, + changeCount: changesToPersist.length, + }, + 'calling persistChanges' + ) + + const persistResult = await persistChanges( + projectId, + changesToPersist, + limits, + endVersion + ) + + if (!persistResult || !persistResult.currentChunk) { + throw new OError( + 'persistChanges did not produce a new chunk for non-empty changes', + { + projectId, + endVersion, + changeCount: changesToPersist.length, + } + ) + } + + const newPersistedChunk = persistResult.currentChunk + const newEndVersion = newPersistedChunk.getEndVersion() + + if (newEndVersion <= endVersion) { + throw new OError( + 'persisted chunk endVersion must be greater than current persisted chunk end version for non-empty changes', + { + projectId, + newEndVersion, + endVersion, + changeCount: changesToPersist.length, + } + ) + } + + logger.debug( + { + projectId, + oldVersion: endVersion, + newVersion: newEndVersion, + }, + 'successfully persisted changes from Redis to main storage' + ) + + // 6. Set the persisted version in Redis. Errors will propagate. + const status = await redisBackend.setPersistedVersion( + projectId, + newEndVersion + ) + + if (status !== 'ok') { + throw new OError('failed to update persisted version in Redis', { + projectId, + newEndVersion, + status, + }) + } + + logger.debug( + { projectId, newEndVersion }, + 'updated persisted version in Redis' + ) + + logger.debug( + { projectId, finalPersistedVersion: newEndVersion }, + 'persistBuffer operation completed successfully' + ) +} + +module.exports = { persistBuffer } diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js index 5b80285eb0..95ffdc67d2 100644 --- a/services/history-v1/storage/lib/persist_changes.js +++ b/services/history-v1/storage/lib/persist_changes.js @@ -57,9 +57,18 @@ Timer.prototype.elapsed = function () { * @param {core.Change[]} allChanges * @param {Object} limits * @param {number} clientEndVersion + * @param {Object} options + * @param {Boolean} [options.queueChangesInRedis] + * If true, queue the changes in Redis for testing purposes. * @return {Promise.} */ -async function persistChanges(projectId, allChanges, limits, clientEndVersion) { +async function persistChanges( + projectId, + allChanges, + limits, + clientEndVersion, + options = {} +) { assert.projectId(projectId) assert.array(allChanges) assert.maybe.object(limits) @@ -289,11 +298,13 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { const numberOfChangesToPersist = oldChanges.length await loadLatestChunk() - try { - await queueChangesInRedis() - await fakePersistRedisChanges() - } catch (err) { - logger.error({ err }, 'Chunk buffer verification failed') + if (options.queueChangesInRedis) { + try { + await queueChangesInRedis() + await fakePersistRedisChanges() + } catch (err) { + logger.error({ err }, 'Chunk buffer verification failed') + } } await extendLastChunkIfPossible() await createNewChunksAsNeeded() diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs new file mode 100644 index 0000000000..64772c4b70 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -0,0 +1,338 @@ +'use strict' + +import fs from 'node:fs' +import { expect } from 'chai' +import { + Change, + Snapshot, + File, + TextOperation, + AddFileOperation, + EditFileOperation, // Added EditFileOperation +} from 'overleaf-editor-core' +import { persistBuffer } from '../../../../storage/lib/persist_buffer.js' +import chunkStore from '../../../../storage/lib/chunk_store/index.js' +import redisBackend from '../../../../storage/lib/chunk_store/redis.js' +import persistChanges from '../../../../storage/lib/persist_changes.js' +import cleanup from './support/cleanup.js' +import fixtures from './support/fixtures.js' +import testFiles from './support/test_files.js' + +describe('persistBuffer', function () { + let projectId + const initialVersion = 0 + let limitsToPersistImmediately + + before(function () { + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 10, + } + }) + + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + beforeEach(async function () { + projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + }) + + describe('with an empty initial chunk (new project)', function () { + it('should persist changes from Redis to a new chunk', async function () { + // create an initial snapshot and add the empty file `main.tex` + const HELLO_TXT = fs.readFileSync(testFiles.path('hello.txt')).toString() + + const createFile = new Change( + [new AddFileOperation('main.tex', File.fromString(HELLO_TXT))], + new Date(), + [] + ) + + await persistChanges( + projectId, + [createFile], + limitsToPersistImmediately, + 0 + ) + // Now queue some changes in Redis + const op1 = new TextOperation().insert('Hello').retain(HELLO_TXT.length) + const change1 = new Change( + [new EditFileOperation('main.tex', op1)], + new Date() + ) + + const op2 = new TextOperation() + .retain('Hello'.length) + .insert(' World') + .retain(HELLO_TXT.length) + const change2 = new Change( + [new EditFileOperation('main.tex', op2)], + new Date() + ) + + const changesToQueue = [change1, change2] + + const finalHeadVersion = initialVersion + 1 + changesToQueue.length + + const now = Date.now() + await redisBackend.queueChanges( + projectId, + new Snapshot(), // dummy snapshot + 1, + changesToQueue, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion(projectId, initialVersion) + + // Persist the changes from Redis to the chunk store + await persistBuffer(projectId) + + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk).to.exist + expect(latestChunk.getStartVersion()).to.equal(initialVersion) + expect(latestChunk.getEndVersion()).to.equal(finalHeadVersion) + expect(latestChunk.getChanges().length).to.equal( + changesToQueue.length + 1 + ) + + const chunkSnapshot = latestChunk.getSnapshot() + expect(Object.keys(chunkSnapshot.getFileMap()).length).to.equal(1) + + const persistedVersionInRedis = (await redisBackend.getState(projectId)) + .persistedVersion + expect(persistedVersionInRedis).to.equal(finalHeadVersion) + + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + finalHeadVersion + ) + expect(nonPersisted).to.be.an('array').that.is.empty + }) + }) + + describe('with an existing chunk and new changes in Redis', function () { + it('should persist new changes from Redis, appending to existing history', async function () { + const initialContent = 'Initial document content.\n' + + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(), + [] + ) + + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Now version is 1 + + const opForChunk1 = new TextOperation() + .retain(initialContent.length) + .insert(' First addition.') + const changesForChunk1 = [ + new Change( + [new EditFileOperation('main.tex', opForChunk1)], + new Date(), + [] + ), + ] + + await persistChanges( + projectId, + changesForChunk1, + limitsToPersistImmediately, // Original limits for this step + versionAfterInitialSetup // Correct clientEndVersion + ) + // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2 + const persistedChunkEndVersion = + versionAfterInitialSetup + changesForChunk1.length + const contentAfterChunk1 = initialContent + ' First addition.' + + const opVersion2 = new TextOperation() + .retain(contentAfterChunk1.length) + .insert(' Second addition.') + const changeVersion2 = new Change( + [new EditFileOperation('main.tex', opVersion2)], + new Date(), + [] + ) + + const contentAfterChange2 = contentAfterChunk1 + ' Second addition.' + const opVersion3 = new TextOperation() + .retain(contentAfterChange2.length) + .insert(' Third addition.') + const changeVersion3 = new Change( + [new EditFileOperation('main.tex', opVersion3)], + new Date(), + [] + ) + + const redisChangesToPush = [changeVersion2, changeVersion3] + const finalHeadVersionAfterRedisPush = + persistedChunkEndVersion + redisChangesToPush.length + const now = Date.now() + + await redisBackend.queueChanges( + projectId, + new Snapshot(), // Use new Snapshot() like in the first test + persistedChunkEndVersion, + redisChangesToPush, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion + ) + + await persistBuffer(projectId) + + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk).to.exist + expect(latestChunk.getStartVersion()).to.equal(0) + expect(latestChunk.getEndVersion()).to.equal( + finalHeadVersionAfterRedisPush + ) + expect(latestChunk.getChanges().length).to.equal( + persistedChunkEndVersion + redisChangesToPush.length + ) + + const persistedVersionInRedisAfter = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(persistedVersionInRedisAfter).to.equal( + finalHeadVersionAfterRedisPush + ) + + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + finalHeadVersionAfterRedisPush + ) + expect(nonPersisted).to.be.an('array').that.is.empty + }) + }) + + describe('when Redis has no new changes', function () { + let persistedChunkEndVersion + let changesForChunk1 + + beforeEach(async function () { + const initialContent = 'Content.' + + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(), + [] + ) + + // Replace chunkStore.create with persistChanges + // clientEndVersion is initialVersion (0). This advances version to 1. + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Now version is 1 + + const opForChunk1 = new TextOperation() + .retain(initialContent.length) + .insert(' More.') + changesForChunk1 = [ + new Change( + [new EditFileOperation('main.tex', opForChunk1)], + new Date(), + [] + ), + ] + // Corrected persistChanges call: clientEndVersion is versionAfterInitialSetup (1) + await persistChanges( + projectId, + changesForChunk1, + limitsToPersistImmediately, // Original limits for this step + versionAfterInitialSetup // Correct clientEndVersion + ) + // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2 + persistedChunkEndVersion = + versionAfterInitialSetup + changesForChunk1.length + }) + + it('should leave the persisted version and stored chunks unchanged', async function () { + const now = Date.now() + await redisBackend.queueChanges( + projectId, + new Snapshot(), + persistedChunkEndVersion - 1, + changesForChunk1, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion + ) + + const chunksBefore = await chunkStore.getProjectChunks(projectId) + + await persistBuffer(projectId) + + const chunksAfter = await chunkStore.getProjectChunks(projectId) + expect(chunksAfter.length).to.equal(chunksBefore.length) + expect(chunksAfter).to.deep.equal(chunksBefore) + + const finalPersistedVersionInRedis = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) + }) + + it('should update the persisted version if it is behind the chunk store end version', async function () { + const now = Date.now() + + await redisBackend.queueChanges( + projectId, + new Snapshot(), + persistedChunkEndVersion - 1, + changesForChunk1, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + // Force the persisted version in Redis to lag behind the chunk store, + // simulating the situation where a worker has persisted changes to the + // chunk store but failed to update the version in redis. + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion - 1 + ) + + const chunksBefore = await chunkStore.getProjectChunks(projectId) + + // Persist buffer (which should do nothing as there are no new changes) + await persistBuffer(projectId, limitsToPersistImmediately) + + const chunksAfter = await chunkStore.getProjectChunks(projectId) + expect(chunksAfter.length).to.equal(chunksBefore.length) + expect(chunksAfter).to.deep.equal(chunksBefore) + + const finalPersistedVersionInRedis = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) + }) + }) +}) From a80203f7489ed441c109fe433668dd7c3531b691 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:50:01 +0100 Subject: [PATCH 021/209] Merge pull request #25909 from overleaf/bg-history-persist-worker add history persist worker GitOrigin-RevId: b9e31e7bdd84570efc0b87b9f5e90b4078551a8c --- .../storage/scripts/persist_redis_chunks.js | 56 ++++ .../js/storage/persist_redis_chunks.test.js | 262 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 services/history-v1/storage/scripts/persist_redis_chunks.js create mode 100644 services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js new file mode 100644 index 0000000000..88964bac69 --- /dev/null +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -0,0 +1,56 @@ +const logger = require('@overleaf/logger') +const commandLineArgs = require('command-line-args') +const redis = require('../lib/redis') +const knex = require('../lib/knex.js') +const knexReadOnly = require('../lib/knex_read_only.js') +const { client } = require('../lib/mongodb.js') +const { scanAndProcessDueItems } = require('../lib/scan') +const { persistBuffer } = require('../lib/persist_buffer') +const { claimPersistJob } = require('../lib/chunk_store/redis') + +const rclient = redis.rclientHistory + +const optionDefinitions = [{ name: 'dry-run', alias: 'd', type: Boolean }] +const options = commandLineArgs(optionDefinitions) +const DRY_RUN = options['dry-run'] || false + +logger.initialize('persist-redis-chunks') + +async function persistProjectAction(projectId) { + const job = await claimPersistJob(projectId) + await persistBuffer(projectId) + if (job && job.close) { + await job.close() + } +} + +async function runPersistChunks() { + await scanAndProcessDueItems( + rclient, + 'persistChunks', + 'persist-time', + persistProjectAction, + DRY_RUN + ) +} + +if (require.main === module) { + runPersistChunks() + .catch(err => { + logger.fatal( + { err, taskName: 'persistChunks' }, + 'Unhandled error in runPersistChunks' + ) + process.exit(1) + }) + .finally(async () => { + await redis.disconnect() + await client.close() + await knex.destroy() + await knexReadOnly.destroy() + }) +} else { + module.exports = { + runPersistChunks, + } +} diff --git a/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js b/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js new file mode 100644 index 0000000000..3f2a4a390f --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js @@ -0,0 +1,262 @@ +'use strict' + +const { expect } = require('chai') +const { + Change, + AddFileOperation, + EditFileOperation, + TextOperation, + File, +} = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const chunkStore = require('../../../../storage/lib/chunk_store') +const { getState } = require('../../../../storage/lib/chunk_store/redis') +const { setupProjectState } = require('./support/redis') +const { runScript } = require('./support/runscript') +const persistChanges = require('../../../../storage/lib/persist_changes') + +const SCRIPT_PATH = 'storage/scripts/persist_redis_chunks.js' + +describe('persist_redis_chunks script', function () { + before(cleanup.everything) + + let now, past, future + let projectIdsStore // To store the generated project IDs, keyed by scenario name + let limitsToPersistImmediately + + before(async function () { + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 100, // Allow enough changes for setup + } + + await fixtures.create() + + now = Date.now() + past = now - 10000 // 10 seconds ago + future = now + 60000 // 1 minute in the future + + projectIdsStore = {} + + // Scenario 1: project_due_for_persistence + // Goal: Has initial persisted content (v1), Redis has new changes (v1->v2) due for persistence. + // Expected: Script persists Redis changes, persistedVersion becomes 2. + { + const dueProjectId = await chunkStore.initializeProject() + projectIdsStore.project_due_for_persistence = dueProjectId + const initialContent = 'Initial content for due project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + dueProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) + const secondChangeDue = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation() + .retain(initialContent.length) + .insert(' More content.') + ), + ], + new Date(now - 20000), // 20 seconds ago + [] + ) + await setupProjectState(dueProjectId, { + persistTime: past, + headVersion: 2, // After secondChangeDue + persistedVersion: 1, // Initial content is at v1 + changes: [secondChangeDue], // New changes in Redis (v1->v2) + expireTimeFuture: true, + }) + } + + // Scenario 2: project_not_due_for_persistence + // Goal: Has initial persisted content (v1), Redis has no new changes, not due. + // Expected: Script does nothing, persistedVersion remains 1. + { + const notDueProjectId = await chunkStore.initializeProject() + projectIdsStore.project_not_due_for_persistence = notDueProjectId + const initialContent = 'Initial content for not_due project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + notDueProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) // Persisted: v0 -> v1 + await setupProjectState(notDueProjectId, { + persistTime: future, + headVersion: 1, // Matches persisted version + persistedVersion: 1, + changes: [], // No new changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 3: project_no_persist_time + // Goal: Has initial persisted content (v1), Redis has no new changes, no persistTime. + // Expected: Script does nothing, persistedVersion remains 1. + { + const noPersistTimeProjectId = await chunkStore.initializeProject() + projectIdsStore.project_no_persist_time = noPersistTimeProjectId + const initialContent = 'Initial content for no_persist_time project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + noPersistTimeProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) // Persisted: v0 -> v1 + await setupProjectState(noPersistTimeProjectId, { + persistTime: null, + headVersion: 1, // Matches persisted version + persistedVersion: 1, + changes: [], // No new changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 4: project_due_fully_persisted + // Goal: Has content persisted up to v2, Redis reflects this (head=2, persisted=2), due for check. + // Expected: Script clears persistTime, persistedVersion remains 2. + { + const dueFullyPersistedId = await chunkStore.initializeProject() + projectIdsStore.project_due_fully_persisted = dueFullyPersistedId + const initialContent = 'Content part 1 for fully persisted.' + const change1 = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 40000), // 40 seconds ago + [] + ) + const change2 = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation() + .retain(initialContent.length) + .insert(' Content part 2.') + ), + ], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + dueFullyPersistedId, + [change1, change2], + limitsToPersistImmediately, + 0 + ) + await setupProjectState(dueFullyPersistedId, { + persistTime: past, + headVersion: 2, + persistedVersion: 2, + changes: [], // No new unpersisted changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 5: project_fails_to_persist + // Goal: Has initial persisted content (v1), Redis has new changes (v1->v2) due for persistence, but these changes will cause an error. + // Expected: Script attempts to persist, fails, and persistTime is NOT cleared. + { + const failsToPersistProjectId = await chunkStore.initializeProject() + projectIdsStore.project_fails_to_persist = failsToPersistProjectId + const initialContent = 'Initial content for failure case.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + failsToPersistProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) + // This change will fail because it tries to insert at a non-existent offset + // assuming the initial content is shorter than 1000 characters. + const conflictingChange = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation().retain(1000).insert('This will fail.') + ), + ], + new Date(now - 20000), // 20 seconds ago + [] + ) + await setupProjectState(failsToPersistProjectId, { + persistTime: past, // Due for persistence + headVersion: 2, // After conflictingChange + persistedVersion: 1, // Initial content is at v1 + changes: [conflictingChange], // New changes in Redis (v1->v2) + expireTimeFuture: true, + }) + } + + await runScript(SCRIPT_PATH) + }) + + describe('when the buffer has new changes', function () { + it('should update persisted-version when the persist-time is in the past', async function () { + const projectId = projectIdsStore.project_due_for_persistence + const state = await getState(projectId) + // console.log('State after running script (project_due_for_persistence):', state) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(2) + }) + + it('should not perform any operations when the persist-time is in the future', async function () { + const projectId = projectIdsStore.project_not_due_for_persistence + const state = await getState(projectId) + expect(state.persistTime).to.equal(future) + expect(state.persistedVersion).to.equal(1) + }) + }) + + describe('when the changes in the buffer are already persisted', function () { + it('should delete persist-time for a project when the persist-time is in the past', async function () { + const projectId = projectIdsStore.project_due_fully_persisted + const state = await getState(projectId) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(2) + }) + }) + + describe('when there is no persist-time set', function () { + it('should not change redis when there is no persist-time set initially', async function () { + const projectId = projectIdsStore.project_no_persist_time + const state = await getState(projectId) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(1) + }) + }) + + describe('when persistence fails due to conflicting changes', function () { + it('should not clear persist-time and not update persisted-version', async function () { + const projectId = projectIdsStore.project_fails_to_persist + const state = await getState(projectId) + expect(state.persistTime).to.be.greaterThan(now) // persistTime should be pushed to the future by RETRY_DELAY_MS + expect(state.persistedVersion).to.equal(1) // persistedVersion should not change + }) + }) +}) From 50df3862e905b903c272b9de6769171bd2ff1b7c Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:50:54 +0100 Subject: [PATCH 022/209] Merge pull request #25954 from overleaf/bg-history-expire-worker-fix fix expire_redis_chunks to only clear job on error GitOrigin-RevId: f7ec435edda95958b453fba501686dcfd84426f7 --- .../history-v1/storage/scripts/expire_redis_chunks.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/services/history-v1/storage/scripts/expire_redis_chunks.js b/services/history-v1/storage/scripts/expire_redis_chunks.js index af2be097b6..60ce4c66f6 100644 --- a/services/history-v1/storage/scripts/expire_redis_chunks.js +++ b/services/history-v1/storage/scripts/expire_redis_chunks.js @@ -14,12 +14,9 @@ logger.initialize('expire-redis-chunks') async function expireProjectAction(projectId) { const job = await claimExpireJob(projectId) - try { - await expireProject(projectId) - } finally { - if (job && job.close) { - await job.close() - } + await expireProject(projectId) + if (job && job.close) { + await job.close() } } From 393cee7af543a82e6b32b2a473509a5539173813 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:51:07 +0100 Subject: [PATCH 023/209] Merge pull request #25993 from overleaf/bg-history-refactor-persist-buffer-limits refactor persist buffer to add limits GitOrigin-RevId: 4a40a7a8812acf5bb7f98bfd7b94d81ebe19fc57 --- .../history-v1/storage/lib/persist_buffer.js | 12 +- .../storage/scripts/persist_redis_chunks.js | 10 +- .../js/storage/persist_buffer.test.mjs | 107 +++++++++++++++++- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 0dfeb9a38c..4cfd7ecab3 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -23,21 +23,13 @@ const redisBackend = require('./chunk_store/redis') * 6. Set the new persisted version (endVersion of the latest persisted chunk) in Redis. * * @param {string} projectId + * @param {Object} limits * @throws {Error | OError} If a critical error occurs during persistence. */ -async function persistBuffer(projectId) { +async function persistBuffer(projectId, limits) { assert.projectId(projectId) logger.debug({ projectId }, 'starting persistBuffer operation') - // Set limits to force us to persist all of the changes. - const farFuture = new Date() - farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) - const limits = { - maxChanges: 0, - minChangeTimestamp: farFuture, - maxChangeTimestamp: farFuture, - } - // 1. Get the latest chunk's endVersion from GCS/main store let endVersion const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId) diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 88964bac69..9d64964f81 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -18,7 +18,15 @@ logger.initialize('persist-redis-chunks') async function persistProjectAction(projectId) { const job = await claimPersistJob(projectId) - await persistBuffer(projectId) + // Set limits to force us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + maxChanges: 0, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + await persistBuffer(projectId, limits) if (job && job.close) { await job.close() } diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 64772c4b70..496d16cd1e 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -92,7 +92,7 @@ describe('persistBuffer', function () { await redisBackend.setPersistedVersion(projectId, initialVersion) // Persist the changes from Redis to the chunk store - await persistBuffer(projectId) + await persistBuffer(projectId, limitsToPersistImmediately) const latestChunk = await chunkStore.loadLatest(projectId) expect(latestChunk).to.exist @@ -196,7 +196,7 @@ describe('persistBuffer', function () { persistedChunkEndVersion ) - await persistBuffer(projectId) + await persistBuffer(projectId, limitsToPersistImmediately) const latestChunk = await chunkStore.loadLatest(projectId) expect(latestChunk).to.exist @@ -287,7 +287,8 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) - await persistBuffer(projectId) + // Persist buffer (which should do nothing as there are no new changes) + await persistBuffer(projectId, limitsToPersistImmediately) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -335,4 +336,104 @@ describe('persistBuffer', function () { expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) }) }) + + describe('when limits restrict the number of changes to persist', function () { + it('should persist only a subset of changes and update persistedVersion accordingly', async function () { + const now = Date.now() + const oneDayAgo = now - 1000 * 60 * 60 * 24 + const oneHourAgo = now - 1000 * 60 * 60 + const twoHoursAgo = now - 1000 * 60 * 60 * 2 + const threeHoursAgo = now - 1000 * 60 * 60 * 3 + + // Create an initial file with some content + const initialContent = 'Initial content.' + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(oneDayAgo), + [] + ) + + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Version is 1 + + // Queue three additional changes in Redis + const op1 = new TextOperation() + .retain(initialContent.length) + .insert(' Change 1.') + const change1 = new Change( + [new EditFileOperation('main.tex', op1)], + new Date(threeHoursAgo) + ) + const contentAfterC1 = initialContent + ' Change 1.' + + const op2 = new TextOperation() + .retain(contentAfterC1.length) + .insert(' Change 2.') + const change2 = new Change( + [new EditFileOperation('main.tex', op2)], + new Date(twoHoursAgo) + ) + const contentAfterC2 = contentAfterC1 + ' Change 2.' + + const op3 = new TextOperation() + .retain(contentAfterC2.length) + .insert(' Change 3.') + const change3 = new Change( + [new EditFileOperation('main.tex', op3)], + new Date(oneHourAgo) + ) + + const changesToQueue = [change1, change2, change3] + await redisBackend.queueChanges( + projectId, + new Snapshot(), // dummy snapshot + versionAfterInitialSetup, // startVersion for queued changes + changesToQueue, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + versionAfterInitialSetup + ) + + // Define limits to only persist 2 additional changes (on top of the initial file creation), + // which should leave the final change (change3) in the redis buffer. + const restrictiveLimits = { + minChangeTimestamp: new Date(oneHourAgo), // only changes more than 1 hour old are considered + maxChangeTimestamp: new Date(twoHoursAgo), // they will be persisted if any change is older than 2 hours + } + + await persistBuffer(projectId, restrictiveLimits) + + // Check the latest persisted chunk, it should only have the initial file and the first two changes + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(latestChunk).to.exist + expect(latestChunk.getChanges().length).to.equal(3) // addInitialFileChange + change1 + change2 + expect(latestChunk.getStartVersion()).to.equal(initialVersion) + const expectedEndVersion = versionAfterInitialSetup + 2 // Persisted two changes from the queue + expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion) + + // Check persisted version in Redis + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(expectedEndVersion) + + // Check non-persisted changes in Redis + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + expectedEndVersion + ) + expect(nonPersisted).to.be.an('array').with.lengthOf(1) // change3 should remain + expect(nonPersisted).to.deep.equal([change3]) + }) + }) }) From 4dbc70b745edafe5b48f3cc0b980f38e9fbcf938 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:31:58 +0200 Subject: [PATCH 024/209] [web] Replace action button to "Go to Account Settings" link in group-settings alert for email confirmation (#25672) * Replace action button to "Go to Account Settings" link in group-settings alert for email confirmation * `bin/run web npm run extract-translations` & `make cleanup_unused_locales` * Fix test capitalization * Update "Go to account settings" to lowercase and link-styling * `bin/run web npm run extract-translations` * Fix test GitOrigin-RevId: d66ce34556bdfc2a37f12900055640cc995ac140 --- services/web/frontend/extracted-translations.json | 3 +-- services/web/locales/en.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 20459e0ed6..506a5bb5f8 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -679,6 +679,7 @@ "go_next_page": "", "go_page": "", "go_prev_page": "", + "go_to_account_settings": "", "go_to_code_location_in_pdf": "", "go_to_overleaf": "", "go_to_pdf_location_in_code": "", @@ -1403,7 +1404,6 @@ "resend": "", "resend_confirmation_code": "", "resend_confirmation_email": "", - "resend_email": "", "resend_group_invite": "", "resend_link_sso": "", "resend_managed_user_invite": "", @@ -1524,7 +1524,6 @@ "send_message": "", "send_request": "", "sending": "", - "sent": "", "server_error": "", "server_pro_license_entitlement_line_1": "", "server_pro_license_entitlement_line_2": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 2efd23fd9f..bdebf3d289 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -888,7 +888,7 @@ "go_next_page": "Go to Next Page", "go_page": "Go to page __page__", "go_prev_page": "Go to Previous Page", - "go_to_account_settings": "Go to Account settings", + "go_to_account_settings": "Go to account settings", "go_to_code_location_in_pdf": "Go to code location in PDF", "go_to_first_page": "Go to first page", "go_to_last_page": "Go to last page", @@ -1848,7 +1848,6 @@ "resend": "Resend", "resend_confirmation_code": "Resend confirmation code", "resend_confirmation_email": "Resend confirmation email", - "resend_email": "Resend email", "resend_group_invite": "Resend group invite", "resend_link_sso": "Resend SSO invite", "resend_managed_user_invite": "Resend managed user invite", From 397016744e2828138f94491b13db64c243343805 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:19 +0200 Subject: [PATCH 025/209] [web] Migrate metrics module Pug files to Bootstrap 5 (#25745) * Remove `bootstrap5PageStatus = 'disabled'` * Update from 'col-xs-' to 'col-' * Rename LESS files to SCSS * Rename local vars * Refactor color variables to use SCSS variables in stylesheets * Remove unused `.superscript` It was added in https://github.com/overleaf/internal/commit/6696ffdd50e1a74f7b8388187386fb076e9ded8f * Remove -moz and -webkit properties * Remove unused(?) `.hub-circle img` * Fix selector specificity for calendar display in daterange-picker * Fix space/tab indents * Fixup btn-link classes: fixes some borders * Add support for svg.nvd3-iddle alongside svg.nvd3-svg in styles * Add dropdown-item classes (improves styles) * Replace `data-toggle` by `data-bs-toggle` * Fixup table: remove .card class, add scope="col", add tbody * Update dropdown caret icon * Update icons to material symbols * Remove green color override for links * Remove/rearrange CSS unrelated to metrics module * Add space after "by" in lags-container (by Day/Week/Month) * Fix SCSS linting * Re-add CSS that belongs in portals module * Use `layout-react` * Put table in Card. It still overflows but looks slightly better * Fix columns breakbpoints * Revert "Use `layout-react`" This reverts commit a9e0d8f5c19d1dfd7417bf67b90799ad199a5913. * Use css variables, use breakpoint mixins * Add `.py-0` on subscriptions table card, so overflows appear less bad GitOrigin-RevId: 55295ad76c112609baf43de4aa606d0c3da7a91f --- services/web/.prettierignore | 2 +- .../frontend/stylesheets/app/admin-hub.less | 156 -- .../web/frontend/stylesheets/app/portals.less | 34 + .../stylesheets/bootstrap-5/modules/all.scss | 7 + .../modules/metrics/admin-hub.scss | 93 ++ .../modules/metrics/daterange-picker.scss | 617 ++++++++ .../modules/metrics/institution-hub.scss} | 28 +- .../modules/metrics/metrics.scss} | 58 +- .../modules/metrics/nvd3.scss} | 1368 ++++++++--------- .../modules/metrics/nvd3_override.scss} | 5 +- .../modules/metrics/publisher-hub.scss} | 36 +- .../bootstrap-5/pages/admin/admin.scss | 5 + .../bootstrap-5/pages/project-list.scss | 2 +- .../components/daterange-picker.less | 656 -------- .../web/frontend/stylesheets/main-style.less | 7 - 15 files changed, 1517 insertions(+), 1557 deletions(-) delete mode 100644 services/web/frontend/stylesheets/app/admin-hub.less create mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss create mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss rename services/web/frontend/stylesheets/{app/institution-hub.less => bootstrap-5/modules/metrics/institution-hub.scss} (52%) rename services/web/frontend/stylesheets/{app/metrics.less => bootstrap-5/modules/metrics/metrics.scss} (78%) rename services/web/frontend/stylesheets/{components/nvd3.less => bootstrap-5/modules/metrics/nvd3.scss} (78%) rename services/web/frontend/stylesheets/{components/nvd3_override.less => bootstrap-5/modules/metrics/nvd3_override.scss} (74%) rename services/web/frontend/stylesheets/{app/publisher-hub.less => bootstrap-5/modules/metrics/publisher-hub.scss} (52%) delete mode 100644 services/web/frontend/stylesheets/components/daterange-picker.less diff --git a/services/web/.prettierignore b/services/web/.prettierignore index f4be187b87..39282c64c2 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -6,7 +6,7 @@ frontend/js/vendor modules/**/frontend/js/vendor public/js public/minjs -frontend/stylesheets/components/nvd3.less +frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs diff --git a/services/web/frontend/stylesheets/app/admin-hub.less b/services/web/frontend/stylesheets/app/admin-hub.less deleted file mode 100644 index bae3312447..0000000000 --- a/services/web/frontend/stylesheets/app/admin-hub.less +++ /dev/null @@ -1,156 +0,0 @@ -.hub-header { - h2 { - display: inline-block; - } - a { - color: @ol-dark-green; - } - i { - font-size: 30px; - } - .dropdown { - margin-right: 10px; - } -} -.admin-item { - position: relative; - margin-bottom: 60px; - .section-title { - text-transform: capitalize; - } - .alert-danger { - color: @ol-red; - } -} -.hidden-chart-section { - display: none; -} -.hub-circle { - display: inline-block; - background-color: @accent-color-secondary; - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - //padding-top: 160px / 6.4; - img { - height: 160px - 160px / 3.2; - } - padding-top: 50px; - color: white; -} -.hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; -} -.hub-big-number { - float: left; - font-size: 32px; - font-weight: 900; - line-height: 40px; - color: @accent-color-secondary; -} -.hub-big-number, -.hub-number-label { - display: block; -} -.hub-metric-link { - position: absolute; - top: 9px; - right: 0; - a { - color: @accent-color-secondary; - } - i { - margin-right: 5px; - } -} -.custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - .chart-center-text { - font-family: @font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: @accent-color-secondary; - text-anchor: middle; - } - - .nv-legend-text { - font-family: @font-family-sans-serif; - font-size: 14px; - } -} -.chart-no-center-text { - .chart-center-text { - display: none; - } -} - -.superscript { - font-size: @font-size-large; -} - -.admin-page { - summary { - // firefox does not show markers for block items - display: list-item; - } -} - -.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; - - &:before { - background: rgb(0, 0, 0); - box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); - border-radius: 8px; - content: ''; - height: 16px; - margin-top: -2px; - position: absolute; - opacity: 0.3; - transition: all 0.2s ease-in-out; - width: 40px; - } - - &:after { - background: rgb(255, 255, 255); - border-radius: 16px; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - content: ''; - height: 24px; - left: -4px; - margin-top: -2px; - position: absolute; - top: -4px; - transition: all 0.2s ease-in-out; - width: 24px; - } - } -} diff --git a/services/web/frontend/stylesheets/app/portals.less b/services/web/frontend/stylesheets/app/portals.less index 9dfd4a57b7..b69176b05f 100644 --- a/services/web/frontend/stylesheets/app/portals.less +++ b/services/web/frontend/stylesheets/app/portals.less @@ -141,4 +141,38 @@ } } } + .hub-circle { + display: inline-block; + background-color: @green-70; + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + padding-top: 50px; + color: white; + } + .hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; + } + .custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + .chart-center-text { + font-family: @font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: @green-70; + text-anchor: middle; + } + + .nv-legend-text { + font-family: @font-family-sans-serif; + font-size: 14px; + } + } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss index b92eb80551..01d58c8c20 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss @@ -1,3 +1,10 @@ +@import 'metrics/admin-hub'; +@import 'metrics/daterange-picker'; +@import 'metrics/institution-hub'; +@import 'metrics/metrics'; +@import 'metrics/nvd3'; +@import 'metrics/nvd3_override'; +@import 'metrics/publisher-hub'; @import 'third-party-references'; @import 'symbol-palette'; @import 'writefull'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss new file mode 100644 index 0000000000..3e6576cf92 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss @@ -0,0 +1,93 @@ +.hub-header { + h2 { + display: inline-block; + } + + .dropdown { + margin-right: var(--spacing-04); + } +} + +.admin-item { + position: relative; + margin-bottom: var(--spacing-12); + + .section-title { + text-transform: capitalize; + } + + .alert-danger { + color: var(--content-danger); + } +} + +.hidden-chart-section { + display: none; +} + +.hub-circle { + display: inline-block; + background-color: var(--green-70); + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + padding-top: 50px; + color: white; +} + +.hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; +} + +.hub-big-number { + float: left; + font-size: 32px; + font-weight: 900; + line-height: 40px; + color: var(--green-70); +} + +.hub-big-number, +.hub-number-label { + display: block; +} + +.hub-metric-link { + position: absolute; + top: 9px; + right: 0; + + i { + margin-right: 5px; + } +} + +.custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + + .chart-center-text { + font-family: $font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: var(--green-70); + text-anchor: middle; + } + + .nv-legend-text { + font-family: $font-family-sans-serif; + font-size: 14px; + } +} + +.chart-no-center-text { + .chart-center-text { + display: none; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss new file mode 100644 index 0000000000..33e466bd91 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss @@ -0,0 +1,617 @@ +// A stylesheet for use with Bootstrap 3.x +// @author: Dan Grossman http://www.dangrossman.info/ +// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. +// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php +// @website: https://www.improvely.com/ + +/* stylelint-disable selector-class-pattern */ + +// VARIABLES + +// Settings + +// The class name to contain everything within. +$arrow-size: 7px; + +// Colors +$daterangepicker-color: $green-50; +$daterangepicker-bg-color: #fff; +$daterangepicker-cell-color: $daterangepicker-color; +$daterangepicker-cell-border-color: transparent; +$daterangepicker-cell-bg-color: $daterangepicker-bg-color; +$daterangepicker-cell-hover-color: $daterangepicker-color; +$daterangepicker-cell-hover-border-color: $daterangepicker-cell-border-color; +$daterangepicker-cell-hover-bg-color: #eee; +$daterangepicker-in-range-color: #000; +$daterangepicker-in-range-border-color: transparent; +$daterangepicker-in-range-bg-color: #ebf4f8; +$daterangepicker-active-color: #fff; +$daterangepicker-active-bg-color: #138a07; +$daterangepicker-active-border-color: transparent; +$daterangepicker-unselected-color: #999; +$daterangepicker-unselected-border-color: transparent; +$daterangepicker-unselected-bg-color: #fff; + +// daterangepicker +$daterangepicker-width: 278px; +$daterangepicker-padding: 4px; +$daterangepicker-z-index: 3000; +$daterangepicker-border-size: 1px; +$daterangepicker-border-color: #ccc; +$daterangepicker-border-radius: 4px; + +// Calendar +$daterangepicker-calendar-margin: $daterangepicker-padding; +$daterangepicker-calendar-bg-color: $daterangepicker-bg-color; +$daterangepicker-calendar-border-size: 1px; +$daterangepicker-calendar-border-color: $daterangepicker-bg-color; +$daterangepicker-calendar-border-radius: $daterangepicker-border-radius; + +// Calendar Cells +$daterangepicker-cell-size: 20px; +$daterangepicker-cell-width: $daterangepicker-cell-size; +$daterangepicker-cell-height: $daterangepicker-cell-size; +$daterangepicker-cell-border-radius: $daterangepicker-calendar-border-radius; +$daterangepicker-cell-border-size: 1px; + +// Dropdowns +$daterangepicker-dropdown-z-index: $daterangepicker-z-index + 1; + +// Controls +$daterangepicker-control-height: 30px; +$daterangepicker-control-line-height: $daterangepicker-control-height; +$daterangepicker-control-color: #555; +$daterangepicker-control-border-size: 1px; +$daterangepicker-control-border-color: #ccc; +$daterangepicker-control-border-radius: 4px; +$daterangepicker-control-active-border-size: 1px; +$daterangepicker-control-active-border-color: $green-50; +$daterangepicker-control-active-border-radius: $daterangepicker-control-border-radius; +$daterangepicker-control-disabled-color: #ccc; + +// Ranges +$daterangepicker-ranges-color: $green-50; +$daterangepicker-ranges-bg-color: daterangepicker-ranges-color; +$daterangepicker-ranges-border-size: 1px; +$daterangepicker-ranges-border-color: $daterangepicker-ranges-bg-color; +$daterangepicker-ranges-border-radius: $daterangepicker-border-radius; +$daterangepicker-ranges-hover-color: #fff; +$daterangepicker-ranges-hover-bg-color: $daterangepicker-ranges-color; +$daterangepicker-ranges-hover-border-size: $daterangepicker-ranges-border-size; +$daterangepicker-ranges-hover-border-color: $daterangepicker-ranges-hover-bg-color; +$daterangepicker-ranges-hover-border-radius: $daterangepicker-border-radius; +$daterangepicker-ranges-active-border-size: $daterangepicker-ranges-border-size; +$daterangepicker-ranges-active-border-color: $daterangepicker-ranges-bg-color; +$daterangepicker-ranges-active-border-radius: $daterangepicker-border-radius; + +// STYLESHEETS +.daterangepicker { + position: absolute; + color: $daterangepicker-color; + background-color: $daterangepicker-bg-color; + border-radius: $daterangepicker-border-radius; + width: $daterangepicker-width; + padding: $daterangepicker-padding; + margin-top: $daterangepicker-border-size; + + // TODO: Should these be parameterized?? + // top: 100px; + // left: 20px; + + $arrow-prefix-size: $arrow-size; + $arrow-suffix-size: ($arrow-size - $daterangepicker-border-size); + + &::before, + &::after { + position: absolute; + display: inline-block; + border-bottom-color: rgb(0 0 0 / 20%); + content: ''; + } + + &::before { + top: -$arrow-prefix-size; + border-right: $arrow-prefix-size solid transparent; + border-left: $arrow-prefix-size solid transparent; + border-bottom: $arrow-prefix-size solid $daterangepicker-border-color; + } + + &::after { + top: -$arrow-suffix-size; + border-right: $arrow-suffix-size solid transparent; + border-bottom: $arrow-suffix-size solid $daterangepicker-bg-color; + border-left: $arrow-suffix-size solid transparent; + } + + &.opensleft { + &::before { + // TODO: Make this relative to prefix size. + right: $arrow-prefix-size + 2px; + } + + &::after { + // TODO: Make this relative to suffix size. + right: $arrow-suffix-size + 4px; + } + } + + &.openscenter { + &::before { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + + &::after { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + } + + &.opensright { + &::before { + // TODO: Make this relative to prefix size. + left: $arrow-prefix-size + 2px; + } + + &::after { + // TODO: Make this relative to suffix size. + left: $arrow-suffix-size + 4px; + } + } + + &.dropup { + margin-top: -5px; + + // NOTE: Note sure why these are special-cased. + &::before { + top: initial; + bottom: -$arrow-prefix-size; + border-bottom: initial; + border-top: $arrow-prefix-size solid $daterangepicker-border-color; + } + + &::after { + top: initial; + bottom: -$arrow-suffix-size; + border-bottom: initial; + border-top: $arrow-suffix-size solid $daterangepicker-bg-color; + } + } + + &.dropdown-menu { + max-width: none; + z-index: $daterangepicker-dropdown-z-index; + } + + &.single { + .ranges, + .calendar { + float: none; + } + } + + /* Calendars */ + &.show-calendar { + .calendar { + display: block; + } + } + + .calendar { + display: none; + max-width: $daterangepicker-width - ($daterangepicker-calendar-margin * 2); + margin: $daterangepicker-calendar-margin; + + &.single { + .calendar-table { + border: none; + } + } + + th, + td { + white-space: nowrap; + text-align: center; + + // TODO: Should this actually be hard-coded? + min-width: 32px; + } + } + + .calendar-table { + border: $daterangepicker-calendar-border-size solid + $daterangepicker-calendar-border-color; + padding: $daterangepicker-calendar-margin; + border-radius: $daterangepicker-calendar-border-radius; + background-color: $daterangepicker-calendar-bg-color; + } + + table { + width: 100%; + margin: 0; + } + + td, + th { + text-align: center; + width: $daterangepicker-cell-width; + height: $daterangepicker-cell-height; + border-radius: $daterangepicker-cell-border-radius; + border: $daterangepicker-cell-border-size solid + $daterangepicker-cell-border-color; + white-space: nowrap; + cursor: pointer; + + &.available { + &:hover { + background-color: $daterangepicker-cell-hover-bg-color; + border-color: $daterangepicker-cell-hover-border-color; + color: $daterangepicker-cell-hover-color; + } + } + + &.week { + font-size: 80%; + color: #ccc; + } + } + + td { + &.off { + &, + &.in-range, + &.start-date, + &.end-date { + background-color: $daterangepicker-unselected-bg-color; + border-color: $daterangepicker-unselected-border-color; + color: $daterangepicker-unselected-color; + } + } + + // Date Range + &.in-range { + background-color: $daterangepicker-in-range-bg-color; + border-color: $daterangepicker-in-range-border-color; + color: $daterangepicker-in-range-color; + + // TODO: Should this be static or should it be parameterized? + border-radius: 0; + } + + &.start-date { + border-radius: $daterangepicker-cell-border-radius 0 0 + $daterangepicker-cell-border-radius; + } + + &.end-date { + border-radius: 0 $daterangepicker-cell-border-radius + $daterangepicker-cell-border-radius 0; + } + + &.start-date.end-date { + border-radius: $daterangepicker-cell-border-radius; + } + + &.active { + &, + &:hover { + background-color: $daterangepicker-active-bg-color; + border-color: $daterangepicker-active-border-color; + color: $daterangepicker-active-color; + } + } + } + + th { + &.month { + width: auto; + } + } + + // Disabled Controls + td, + option { + &.disabled { + color: #999; + cursor: not-allowed; + text-decoration: line-through; + } + } + + select { + &.monthselect, + &.yearselect { + font-size: 12px; + padding: 1px; + height: auto; + margin: 0; + cursor: default; + } + + &.monthselect { + margin-right: 2%; + width: 56%; + } + + &.yearselect { + width: 40%; + } + + &.hourselect, + &.minuteselect, + &.secondselect, + &.ampmselect { + width: 50px; + margin-bottom: 0; + } + } + + // Text Input Controls (above calendar) + .input-mini { + border: $daterangepicker-control-border-size solid + $daterangepicker-control-border-color; + border-radius: $daterangepicker-control-border-radius; + color: $daterangepicker-control-color; + height: $daterangepicker-control-line-height; + line-height: $daterangepicker-control-height; + display: block; + vertical-align: middle; + + // TODO: Should these all be static, too?? + margin: 0 0 5px; + padding: 0 6px 0 28px; + width: 100%; + + &.active { + border: $daterangepicker-control-active-border-size solid + $daterangepicker-control-active-border-color; + border-radius: $daterangepicker-control-active-border-radius; + } + } + + .daterangepicker_input { + position: relative; + padding-left: 0; + + i { + position: absolute; + + // NOTE: These appear to be eyeballed to me... + left: 8px; + top: var(--spacing-04); + } + } + + &.rtl { + .input-mini { + padding-right: 28px; + padding-left: 6px; + } + + .daterangepicker_input i { + left: auto; + right: 8px; + } + } + + // Time Picker + .calendar-time { + text-align: center; + margin: 5px auto; + line-height: $daterangepicker-control-line-height; + position: relative; + padding-left: 28px; + + select { + &.disabled { + color: $daterangepicker-control-disabled-color; + cursor: not-allowed; + } + } + } +} + +// Predefined Ranges +.ranges { + font-size: 11px; + float: none; + margin: 4px; + text-align: left; + + ul { + list-style: none; + margin: 0 auto; + padding: 0; + width: 100%; + } + + li { + font-size: 13px; + background-color: $daterangepicker-ranges-bg-color; + border: $daterangepicker-ranges-border-size solid + $daterangepicker-ranges-border-color; + border-radius: $daterangepicker-ranges-border-radius; + color: $daterangepicker-ranges-color; + padding: 3px 12px; + margin-bottom: 8px; + cursor: pointer; + + &:hover { + background-color: $daterangepicker-ranges-hover-bg-color; + color: $daterangepicker-ranges-hover-color; + } + + &.active { + background-color: $daterangepicker-ranges-hover-bg-color; + border: $daterangepicker-ranges-hover-border-size solid + $daterangepicker-ranges-hover-border-color; + color: $daterangepicker-ranges-hover-color; + } + } +} + +/* Larger Screen Styling */ +@include media-breakpoint-up(sm) { + .daterangepicker { + .glyphicon { + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ + font-family: FontAwesome; + } + + .glyphicon-chevron-left::before { + content: '\f053'; + } + + .glyphicon-chevron-right::before { + content: '\f054'; + } + + .glyphicon-calendar::before { + content: '\f073'; + } + + width: auto; + + .ranges { + ul { + width: 160px; + } + } + + &.single { + .ranges { + ul { + width: 100%; + } + } + + .calendar.left { + clear: none; + } + + &.ltr { + .ranges, + .calendar { + float: left; + } + } + + &.rtl { + .ranges, + .calendar { + float: right; + } + } + } + + &.ltr { + direction: ltr; + text-align: left; + + .calendar { + &.left { + clear: left; + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 12px; + } + } + + &.right { + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-right: 12px; + } + + .ranges, + .calendar { + float: left; + } + } + + &.rtl { + direction: rtl; + text-align: right; + + .calendar { + &.left { + clear: right; + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding-left: 12px; + } + } + + &.right { + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + + .ranges, + .calendar { + text-align: right; + float: right; + } + } + } +} + +@include media-breakpoint-up(md) { + /* force the calendar to display on one row */ + .show-calendar { + min-width: 658px; /* width of all contained elements, IE/Edge fallback */ + width: max-content; + } + + .daterangepicker { + .ranges { + width: auto; + } + + &.ltr { + .ranges { + float: left; + clear: none !important; + } + } + + &.rtl { + .ranges { + float: right; + } + } + + .calendar { + clear: none !important; + } + } +} diff --git a/services/web/frontend/stylesheets/app/institution-hub.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss similarity index 52% rename from services/web/frontend/stylesheets/app/institution-hub.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss index cb705e4b99..67cbe580e4 100644 --- a/services/web/frontend/stylesheets/app/institution-hub.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss @@ -1,38 +1,54 @@ #institution-hub { - .section_header { + .section-header { .dropdown { - margin-right: 10px; + margin-right: var(--spacing-04); } } #usage { .recent-activity { .overbox { - font-size: 16px; + @include body-base; } + .hub-big-number, .hub-number-label, .worked-on { display: block; width: 50%; } + .hub-big-number { - padding-right: 10px; + padding-right: var(--spacing-04); text-align: right; } + .hub-number-label, .worked-on { float: right; } + .hub-number-label { &:nth-child(odd) { - margin-top: 16px; + margin-top: var(--spacing-06); } } + .worked-on { - color: @text-small-color; + color: var(--content-secondary); font-style: italic; } } } + + .overbox { + margin: 0; + padding: var(--spacing-10) var(--spacing-07); + background: var(--white); + border: 1px solid var(--content-disabled); + + &.overbox-small { + padding: var(--spacing-04); + } + } } diff --git a/services/web/frontend/stylesheets/app/metrics.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss similarity index 78% rename from services/web/frontend/stylesheets/app/metrics.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss index 5256b8a8bd..32cde9c522 100644 --- a/services/web/frontend/stylesheets/app/metrics.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss @@ -1,6 +1,6 @@ #metrics { max-width: none; - padding: 0 30px; + padding: 0 var(--spacing-09); width: auto; svg.nvd3-svg { @@ -9,17 +9,18 @@ .overbox { margin: 0; - padding: 40px 20px; + padding: var(--spacing-10) var(--spacing-07); background: #fff; border: 1px solid #dfdfdf; + .box { - padding-bottom: 30px; + padding-bottom: var(--spacing-09); overflow: hidden; - margin-bottom: 40px; - border-bottom: 1px solid rgb(216, 216, 216); + margin-bottom: var(--spacing-10); + border-bottom: 1px solid rgb(216 216 216); .header { - margin-bottom: 20px; + margin-bottom: var(--spacing-07); h4 { font-size: 19px; @@ -27,10 +28,14 @@ } } } + + &.overbox-small { + padding: var(--spacing-04); + } } .print-button { - margin-right: 10px; + margin-right: var(--spacing-04); font-size: 20px; } @@ -40,21 +45,17 @@ } .metric-col { - padding: 15px; - } - - .metric-header-container { - h4 { - margin-bottom: 0; - } + padding: var(--spacing-06); } svg { display: block; height: 250px; + text { font-family: 'Open Sans', sans-serif; } + &:not(:root) { overflow: visible; } @@ -79,6 +80,10 @@ // BEGIN: Metrics header .metric-header-container { + h4 { + margin-bottom: 0; + } + > h4 { margin-top: 0; margin-bottom: 0; @@ -89,12 +94,14 @@ font-size: 0.5em; } } + // END: Metrics header // BEGIN: Metrics footer .metric-footer-container { text-align: center; } + // END: Metrics footer // BEGIN: Metrics overlays @@ -107,7 +114,7 @@ height: 100%; width: 100%; padding: 16px; /* 15px of .metric-col padding + 1px border */ - padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/ + padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding */ } .metric-overlay-loading { @@ -129,19 +136,20 @@ width: 100%; height: 100%; } + // END: Metrics overlays } #metrics-header { - @media (min-width: 1200px) { - margin-bottom: 30px; + @include media-breakpoint-up(lg) { + margin-bottom: var(--spacing-09); } h3 { display: inline-block; } - .section_header { + .section-header { margin-bottom: 0; } @@ -162,9 +170,11 @@ #dates-container { display: inline-block; + .daterangepicker { - margin-right: 15px; + margin-right: var(--spacing-06); } + #metrics-dates { padding: 0; } @@ -172,14 +182,10 @@ } #metrics-footer { - margin-top: 30px; + margin-top: var(--spacing-09); text-align: center; } -body.print-loading { - #metrics { - .metric-col { - opacity: 0.5; - } - } +body.print-loading #metrics .metric-col { + opacity: 0.5; } diff --git a/services/web/frontend/stylesheets/components/nvd3.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss similarity index 78% rename from services/web/frontend/stylesheets/components/nvd3.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss index f1fea65901..4983129a80 100755 --- a/services/web/frontend/stylesheets/components/nvd3.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss @@ -1,691 +1,677 @@ -/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ -.nvd3 .nv-axis { - pointer-events: none; - opacity: 1; -} - -.nvd3 .nv-axis path { - fill: none; - stroke: #000; - stroke-opacity: 0.75; - shape-rendering: crispEdges; -} - -.nvd3 .nv-axis path.domain { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis.nv-x path.domain { - stroke-opacity: 0; -} - -.nvd3 .nv-axis line { - fill: none; - stroke: #e5e5e5; - shape-rendering: crispEdges; -} - -.nvd3 .nv-axis .zero line, - /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis .nv-axisMaxMin text { - font-weight: bold; -} - -.nvd3 .x .nv-axis .nv-axisMaxMin text, -.nvd3 .x2 .nv-axis .nv-axisMaxMin text, -.nvd3 .x3 .nv-axis .nv-axisMaxMin text { - text-anchor: middle; -} - -.nvd3 .nv-axis.nv-disabled { - opacity: 0; -} - -.nvd3 .nv-bars rect { - fill-opacity: 0.75; - - transition: fill-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-bars rect.hover { - fill-opacity: 1; -} - -.nvd3 .nv-bars .hover rect { - fill: lightblue; -} - -.nvd3 .nv-bars text { - fill: rgba(0, 0, 0, 0); -} - -.nvd3 .nv-bars .hover text { - fill: rgba(0, 0, 0, 1); -} - -.nvd3 .nv-multibar .nv-groups rect, -.nvd3 .nv-multibarHorizontal .nv-groups rect, -.nvd3 .nv-discretebar .nv-groups rect { - stroke-opacity: 0; - - transition: fill-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-multibar .nv-groups rect:hover, -.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, -.nvd3 .nv-candlestickBar .nv-ticks rect:hover, -.nvd3 .nv-discretebar .nv-groups rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-discretebar .nv-groups text, -.nvd3 .nv-multibarHorizontal .nv-groups text { - font-weight: bold; - fill: rgba(0, 0, 0, 1); - stroke: rgba(0, 0, 0, 0); -} - -/* boxplot CSS */ -.nvd3 .nv-boxplot circle { - fill-opacity: 0.5; -} - -.nvd3 .nv-boxplot circle:hover { - fill-opacity: 1; -} - -.nvd3 .nv-boxplot rect:hover { - fill-opacity: 1; -} - -.nvd3 line.nv-boxplot-median { - stroke: black; -} - -.nv-boxplot-tick:hover { - stroke-width: 2.5px; -} -/* bullet */ -.nvd3.nv-bullet { - font: 10px sans-serif; -} -.nvd3.nv-bullet .nv-measure { - fill-opacity: 0.8; -} -.nvd3.nv-bullet .nv-measure:hover { - fill-opacity: 1; -} -.nvd3.nv-bullet .nv-marker { - stroke: #000; - stroke-width: 2px; -} -.nvd3.nv-bullet .nv-markerTriangle { - stroke: #000; - fill: #fff; - stroke-width: 1.5px; -} -.nvd3.nv-bullet .nv-markerLine { - stroke: #000; - stroke-width: 1.5px; -} -.nvd3.nv-bullet .nv-tick line { - stroke: #666; - stroke-width: 0.5px; -} -.nvd3.nv-bullet .nv-range.nv-s0 { - fill: #eee; -} -.nvd3.nv-bullet .nv-range.nv-s1 { - fill: #ddd; -} -.nvd3.nv-bullet .nv-range.nv-s2 { - fill: #ccc; -} -.nvd3.nv-bullet .nv-title { - font-size: 14px; - font-weight: bold; -} -.nvd3.nv-bullet .nv-subtitle { - fill: #999; -} - -.nvd3.nv-bullet .nv-range { - fill: #bababa; - fill-opacity: 0.4; -} -.nvd3.nv-bullet .nv-range:hover { - fill-opacity: 0.7; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { - stroke: #d62728; - fill: #d62728; -} - -.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-candlestickBar .nv-ticks line { - stroke: #333; -} - -.nv-force-node { - stroke: #fff; - stroke-width: 1.5px; -} -.nv-force-link { - stroke: #999; - stroke-opacity: 0.6; -} -.nv-force-node text { - stroke-width: 0px; -} - -.nvd3 .nv-legend .nv-disabled rect { - /*fill-opacity: 0;*/ -} - -.nvd3 .nv-check-box .nv-box { - fill-opacity: 0; - stroke-width: 2; -} - -.nvd3 .nv-check-box .nv-check { - fill-opacity: 0; - stroke-width: 4; -} - -.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { - opacity: 0; -} - -/* line plus bar */ -.nvd3.nv-linePlusBar .nv-bar rect { - fill-opacity: 0.75; -} - -.nvd3.nv-linePlusBar .nv-bar rect:hover { - fill-opacity: 1; -} -.nvd3 .nv-groups path.nv-line { - fill: none; -} - -.nvd3 .nv-groups path.nv-area { - stroke: none; -} - -.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { - fill-opacity: 0.5 !important; - stroke-opacity: 0.5 !important; -} - -.with-transitions .nvd3 .nv-groups .nv-point { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-scatter .nv-groups .nv-point.hover, -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 7px; - fill-opacity: 0.95 !important; - stroke-opacity: 0.95 !important; -} - -.nvd3 .nv-point-paths path { - stroke: #aaa; - stroke-opacity: 0; - fill: #eee; - fill-opacity: 0; -} - -.nvd3 .nv-indexLine { - cursor: ew-resize; -} - -/******************** - * SVG CSS - */ - -/******************** - Default CSS for an svg element nvd3 used -*/ -svg.nvd3-svg { - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - user-select: none; - display: block; - width: 100%; - height: 100%; -} - -/******************** - Box shadow and border radius styling -*/ -.nvtooltip.with-3d-shadow, -.with-3d-shadow .nvtooltip { - -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} - -.nvd3 text { - font: normal 12px Arial; -} - -.nvd3 .title { - font: bold 14px Arial; -} - -.nvd3 .nv-background { - fill: white; - fill-opacity: 0; -} - -.nvd3.nv-noData { - font-size: 18px; - font-weight: bold; -} - -/********** -* Brush -*/ - -.nv-brush .extent { - fill-opacity: 0.125; - shape-rendering: crispEdges; -} - -.nv-brush .resize path { - fill: #eee; - stroke: #666; -} - -/********** -* Legend -*/ - -.nvd3 .nv-legend .nv-series { - cursor: pointer; -} - -.nvd3 .nv-legend .nv-disabled circle { - fill-opacity: 0; -} - -/* focus */ -.nvd3 .nv-brush .extent { - fill-opacity: 0 !important; -} - -.nvd3 .nv-brushBackground rect { - stroke: #000; - stroke-width: 0.4; - fill: #fff; - fill-opacity: 0.7; -} - -/********** -* Print -*/ - -@media print { - .nvd3 text { - stroke-width: 0; - fill-opacity: 1; - } -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { - stroke: #2ca02c; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { - stroke: #d62728; -} - -.nvd3 .background path { - fill: none; - stroke: #eee; - stroke-opacity: 0.4; - shape-rendering: crispEdges; -} - -.nvd3 .foreground path { - fill: none; - stroke-opacity: 0.7; -} - -.nvd3 .nv-parallelCoordinates-brush .extent { - fill: #fff; - fill-opacity: 0.6; - stroke: gray; - shape-rendering: crispEdges; -} - -.nvd3 .nv-parallelCoordinates .hover { - fill-opacity: 1; - stroke-width: 3px; -} - -.nvd3 .missingValuesline line { - fill: none; - stroke: black; - stroke-width: 1; - stroke-opacity: 1; - stroke-dasharray: 5, 5; -} -.nvd3.nv-pie path { - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; -} - -.nvd3.nv-pie .nv-pie-title { - font-size: 24px; - fill: rgba(19, 196, 249, 0.59); -} - -.nvd3.nv-pie .nv-slice text { - stroke: #000; - stroke-width: 0; -} - -.nvd3.nv-pie path { - stroke: #fff; - stroke-width: 1px; - stroke-opacity: 1; -} - -.nvd3.nv-pie path { - fill-opacity: 0.7; -} -.nvd3.nv-pie .hover path { - fill-opacity: 1; -} -.nvd3.nv-pie .nv-label { - pointer-events: none; -} -.nvd3.nv-pie .nv-label rect { - fill-opacity: 0; - stroke-opacity: 0; -} - -/* scatter */ -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 20px; - stroke-opacity: 0.5; -} - -.nvd3 .nv-scatter .nv-point.hover { - fill-opacity: 1; -} -.nv-noninteractive { - pointer-events: none; -} - -.nv-distx, -.nv-disty { - pointer-events: none; -} - -/* sparkline */ -.nvd3.nv-sparkline path { - fill: none; -} - -.nvd3.nv-sparklineplus g.nv-hoverValue { - pointer-events: none; -} - -.nvd3.nv-sparklineplus .nv-hoverValue line { - stroke: #333; - stroke-width: 1.5px; -} - -.nvd3.nv-sparklineplus, -.nvd3.nv-sparklineplus g { - pointer-events: all; -} - -.nvd3 .nv-hoverArea { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-sparklineplus .nv-xValue, -.nvd3.nv-sparklineplus .nv-yValue { - stroke-width: 0; - font-size: 0.9em; - font-weight: normal; -} - -.nvd3.nv-sparklineplus .nv-yValue { - stroke: #f66; -} - -.nvd3.nv-sparklineplus .nv-maxValue { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-sparklineplus .nv-minValue { - stroke: #d62728; - fill: #d62728; -} - -.nvd3.nv-sparklineplus .nv-currentValue { - font-weight: bold; - font-size: 1.1em; -} -/* stacked area */ -.nvd3.nv-stackedarea path.nv-area { - fill-opacity: 0.7; - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-stackedarea path.nv-area.hover { - fill-opacity: 0.9; -} - -.nvd3.nv-stackedarea .nv-groups .nv-point { - stroke-opacity: 0; - fill-opacity: 0; -} - -.nvtooltip { - position: absolute; - background-color: rgba(255, 255, 255, 1); - color: rgba(0, 0, 0, 1); - padding: 1px; - border: 1px solid rgba(0, 0, 0, 0.2); - z-index: 10000; - display: block; - - font-family: Arial; - font-size: 13px; - text-align: left; - pointer-events: none; - - white-space: nowrap; - - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.nvtooltip { - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(0, 0, 0, 0.5); - border-radius: 4px; -} - -/*Give tooltips that old fade in transition by - putting a "with-transitions" class on the container div. -*/ -.nvtooltip.with-transitions, -.with-transitions .nvtooltip { - transition: opacity 50ms linear; - -moz-transition: opacity 50ms linear; - -webkit-transition: opacity 50ms linear; - - transition-delay: 200ms; - -moz-transition-delay: 200ms; - -webkit-transition-delay: 200ms; -} - -.nvtooltip.x-nvtooltip, -.nvtooltip.y-nvtooltip { - padding: 8px; -} - -.nvtooltip h3 { - margin: 0; - padding: 4px 14px; - line-height: 18px; - font-weight: normal; - background-color: rgba(247, 247, 247, 0.75); - color: rgba(0, 0, 0, 1); - text-align: center; - - border-bottom: 1px solid #ebebeb; - - -webkit-border-radius: 5px 5px 0 0; - -moz-border-radius: 5px 5px 0 0; - border-radius: 5px 5px 0 0; -} - -.nvtooltip p { - margin: 0; - padding: 5px 14px; - text-align: center; -} - -.nvtooltip span { - display: inline-block; - margin: 2px 0; -} - -.nvtooltip table { - margin: 6px; - border-spacing: 0; -} - -.nvtooltip table td { - padding: 2px 9px 2px 0; - vertical-align: middle; -} - -.nvtooltip table td.key { - font-weight: normal; -} -.nvtooltip table td.key.total { - font-weight: bold; -} -.nvtooltip table td.value { - text-align: right; - font-weight: bold; -} - -.nvtooltip table td.percent { - color: darkgray; -} - -.nvtooltip table tr.highlight td { - padding: 1px 9px 1px 0; - border-bottom-style: solid; - border-bottom-width: 1px; - border-top-style: solid; - border-top-width: 1px; -} - -.nvtooltip table td.legend-color-guide div { - width: 8px; - height: 8px; - vertical-align: middle; -} - -.nvtooltip table td.legend-color-guide div { - width: 12px; - height: 12px; - border: 1px solid #999; -} - -.nvtooltip .footer { - padding: 3px; - text-align: center; -} - -.nvtooltip-pending-removal { - pointer-events: none; - display: none; -} - -/**** -Interactive Layer -*/ -.nvd3 .nv-interactiveGuideLine { - pointer-events: none; -} -.nvd3 line.nv-guideline { - stroke: #ccc; -} +/* stylelint-disable */ + +/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ +.nvd3 .nv-axis { + pointer-events: none; + opacity: 1; +} + +.nvd3 .nv-axis path { + fill: none; + stroke: #000; + stroke-opacity: 0.75; + shape-rendering: crispedges; +} + +.nvd3 .nv-axis path.domain { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis.nv-x path.domain { + stroke-opacity: 0; +} + +.nvd3 .nv-axis line { + fill: none; + stroke: #e5e5e5; + shape-rendering: crispedges; +} + +.nvd3 .nv-axis .zero line, + /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis .nv-axisMaxMin text { + font-weight: bold; +} + +.nvd3 .x .nv-axis .nv-axisMaxMin text, +.nvd3 .x2 .nv-axis .nv-axisMaxMin text, +.nvd3 .x3 .nv-axis .nv-axisMaxMin text { + text-anchor: middle; +} + +.nvd3 .nv-axis.nv-disabled { + opacity: 0; +} + +.nvd3 .nv-bars rect { + fill-opacity: 0.75; + transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-bars rect.hover { + fill-opacity: 1; +} + +.nvd3 .nv-bars .hover rect { + fill: lightblue; +} + +.nvd3 .nv-bars text { + fill: rgb(0 0 0 / 0%); +} + +.nvd3 .nv-bars .hover text { + fill: rgb(0 0 0 / 100%); +} + +.nvd3 .nv-multibar .nv-groups rect, +.nvd3 .nv-multibarHorizontal .nv-groups rect, +.nvd3 .nv-discretebar .nv-groups rect { + stroke-opacity: 0; + transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-multibar .nv-groups rect:hover, +.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, +.nvd3 .nv-candlestickBar .nv-ticks rect:hover, +.nvd3 .nv-discretebar .nv-groups rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-discretebar .nv-groups text, +.nvd3 .nv-multibarHorizontal .nv-groups text { + font-weight: bold; + fill: rgb(0 0 0 / 100%); + stroke: rgb(0 0 0 / 0%); +} + +/* boxplot CSS */ +.nvd3 .nv-boxplot circle { + fill-opacity: 0.5; +} + +.nvd3 .nv-boxplot circle:hover { + fill-opacity: 1; +} + +.nvd3 .nv-boxplot rect:hover { + fill-opacity: 1; +} + +.nvd3 line.nv-boxplot-median { + stroke: black; +} + +.nv-boxplot-tick:hover { + stroke-width: 2.5px; +} + +/* bullet */ +.nvd3.nv-bullet { + font: 10px sans-serif; +} + +.nvd3.nv-bullet .nv-measure { + fill-opacity: 0.8; +} + +.nvd3.nv-bullet .nv-measure:hover { + fill-opacity: 1; +} + +.nvd3.nv-bullet .nv-marker { + stroke: #000; + stroke-width: 2px; +} + +.nvd3.nv-bullet .nv-markerTriangle { + stroke: #000; + fill: #fff; + stroke-width: 1.5px; +} + +.nvd3.nv-bullet .nv-markerLine { + stroke: #000; + stroke-width: 1.5px; +} + +.nvd3.nv-bullet .nv-tick line { + stroke: #666; + stroke-width: 0.5px; +} + +.nvd3.nv-bullet .nv-range.nv-s0 { + fill: #eee; +} + +.nvd3.nv-bullet .nv-range.nv-s1 { + fill: #ddd; +} + +.nvd3.nv-bullet .nv-range.nv-s2 { + fill: #ccc; +} + +.nvd3.nv-bullet .nv-title { + font-size: 14px; + font-weight: bold; +} + +.nvd3.nv-bullet .nv-subtitle { + fill: #999; +} + +.nvd3.nv-bullet .nv-range { + fill: #bababa; + fill-opacity: 0.4; +} + +.nvd3.nv-bullet .nv-range:hover { + fill-opacity: 0.7; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { + stroke: #d62728; + fill: #d62728; +} + +.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-candlestickBar .nv-ticks line { + stroke: #333; +} + +.nv-force-node { + stroke: #fff; + stroke-width: 1.5px; +} + +.nv-force-link { + stroke: #999; + stroke-opacity: 0.6; +} + +.nv-force-node text { + stroke-width: 0; +} + +.nvd3 .nv-legend .nv-disabled rect { + /* fill-opacity: 0; */ +} + +.nvd3 .nv-check-box .nv-box { + fill-opacity: 0; + stroke-width: 2; +} + +.nvd3 .nv-check-box .nv-check { + fill-opacity: 0; + stroke-width: 4; +} + +.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { + opacity: 0; +} + +/* line plus bar */ +.nvd3.nv-linePlusBar .nv-bar rect { + fill-opacity: 0.75; +} + +.nvd3.nv-linePlusBar .nv-bar rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-groups path.nv-line { + fill: none; +} + +.nvd3 .nv-groups path.nv-area { + stroke: none; +} + +.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { + fill-opacity: 0.5 !important; + stroke-opacity: 0.5 !important; +} + +.with-transitions .nvd3 .nv-groups .nv-point { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-scatter .nv-groups .nv-point.hover, +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 7px; + fill-opacity: 0.95 !important; + stroke-opacity: 0.95 !important; +} + +.nvd3 .nv-point-paths path { + stroke: #aaa; + stroke-opacity: 0; + fill: #eee; + fill-opacity: 0; +} + +.nvd3 .nv-indexLine { + cursor: ew-resize; +} + +/******************** + * SVG CSS + */ + +/******************** + Default CSS for an svg element nvd3 used +*/ +svg.nvd3-svg, svg.nvd3-iddle { + -webkit-touch-callout: none; + user-select: none; + display: block; + width: 100%; + height: 100%; +} + +/******************** + Box shadow and border radius styling +*/ +.nvtooltip.with-3d-shadow, +.with-3d-shadow .nvtooltip { + box-shadow: 0 5px 10px rgb(0 0 0 / 20%); + border-radius: 5px; +} + +.nvd3 text { + font: normal 12px Arial; +} + +.nvd3 .title { + font: bold 14px Arial; +} + +.nvd3 .nv-background { + fill: white; + fill-opacity: 0; +} + +.nvd3.nv-noData { + font-size: 18px; + font-weight: bold; +} + +/********** +* Brush +*/ + +.nv-brush .extent { + fill-opacity: 0.125; + shape-rendering: crispedges; +} + +.nv-brush .resize path { + fill: #eee; + stroke: #666; +} + +/********** +* Legend +*/ + +.nvd3 .nv-legend .nv-series { + cursor: pointer; +} + +.nvd3 .nv-legend .nv-disabled circle { + fill-opacity: 0; +} + +/* focus */ +.nvd3 .nv-brush .extent { + fill-opacity: 0 !important; +} + +.nvd3 .nv-brushBackground rect { + stroke: #000; + stroke-width: 0.4; + fill: #fff; + fill-opacity: 0.7; +} + +/********** +* Print +*/ + +@media print { + .nvd3 text { + stroke-width: 0; + fill-opacity: 1; + } +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { + stroke: #2ca02c; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { + stroke: #d62728; +} + +.nvd3 .background path { + fill: none; + stroke: #eee; + stroke-opacity: 0.4; + shape-rendering: crispedges; +} + +.nvd3 .foreground path { + fill: none; + stroke-opacity: 0.7; +} + +.nvd3 .nv-parallelCoordinates-brush .extent { + fill: #fff; + fill-opacity: 0.6; + stroke: gray; + shape-rendering: crispedges; +} + +.nvd3 .nv-parallelCoordinates .hover { + fill-opacity: 1; + stroke-width: 3px; +} + +.nvd3 .missingValuesline line { + fill: none; + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + stroke-dasharray: 5, 5; +} + +.nvd3.nv-pie path { + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; +} + +.nvd3.nv-pie .nv-pie-title { + font-size: 24px; + fill: rgb(19 196 249 / 59%); +} + +.nvd3.nv-pie .nv-slice text { + stroke: #000; + stroke-width: 0; +} + +.nvd3.nv-pie path { + stroke: #fff; + stroke-width: 1px; + stroke-opacity: 1; +} + +.nvd3.nv-pie path { + fill-opacity: 0.7; +} + +.nvd3.nv-pie .hover path { + fill-opacity: 1; +} + +.nvd3.nv-pie .nv-label { + pointer-events: none; +} + +.nvd3.nv-pie .nv-label rect { + fill-opacity: 0; + stroke-opacity: 0; +} + +/* scatter */ +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 20px; + stroke-opacity: 0.5; +} + +.nvd3 .nv-scatter .nv-point.hover { + fill-opacity: 1; +} + +.nv-noninteractive { + pointer-events: none; +} + +.nv-distx, +.nv-disty { + pointer-events: none; +} + +/* sparkline */ +.nvd3.nv-sparkline path { + fill: none; +} + +.nvd3.nv-sparklineplus g.nv-hoverValue { + pointer-events: none; +} + +.nvd3.nv-sparklineplus .nv-hoverValue line { + stroke: #333; + stroke-width: 1.5px; +} + +.nvd3.nv-sparklineplus, +.nvd3.nv-sparklineplus g { + pointer-events: all; +} + +.nvd3 .nv-hoverArea { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-sparklineplus .nv-xValue, +.nvd3.nv-sparklineplus .nv-yValue { + stroke-width: 0; + font-size: 0.9em; + font-weight: normal; +} + +.nvd3.nv-sparklineplus .nv-yValue { + stroke: #f66; +} + +.nvd3.nv-sparklineplus .nv-maxValue { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-sparklineplus .nv-minValue { + stroke: #d62728; + fill: #d62728; +} + +.nvd3.nv-sparklineplus .nv-currentValue { + font-weight: bold; + font-size: 1.1em; +} + +/* stacked area */ +.nvd3.nv-stackedarea path.nv-area { + fill-opacity: 0.7; + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-stackedarea path.nv-area.hover { + fill-opacity: 0.9; +} + +.nvd3.nv-stackedarea .nv-groups .nv-point { + stroke-opacity: 0; + fill-opacity: 0; +} + +.nvtooltip { + position: absolute; + background-color: rgb(255 255 255 / 100%); + color: rgb(0 0 0 / 100%); + padding: 1px; + border: 1px solid rgb(0 0 0 / 20%); + z-index: 10000; + display: block; + font-family: Arial; + font-size: 13px; + text-align: left; + pointer-events: none; + white-space: nowrap; + -webkit-touch-callout: none; + user-select: none; +} + +.nvtooltip { + background: rgb(255 255 255 / 80%); + border: 1px solid rgb(0 0 0 / 50%); + border-radius: 4px; +} + +/* Give tooltips that old fade in transition by + putting a "with-transitions" class on the container div. +*/ +.nvtooltip.with-transitions, +.with-transitions .nvtooltip { + transition: opacity 50ms linear; + transition-delay: 200ms; +} + +.nvtooltip.x-nvtooltip, +.nvtooltip.y-nvtooltip { + padding: 8px; +} + +.nvtooltip h3 { + margin: 0; + padding: 4px 14px; + line-height: 18px; + font-weight: normal; + background-color: rgb(247 247 247 / 75%); + color: rgb(0 0 0 / 100%); + text-align: center; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} + +.nvtooltip p { + margin: 0; + padding: 5px 14px; + text-align: center; +} + +.nvtooltip span { + display: inline-block; + margin: 2px 0; +} + +.nvtooltip table { + margin: 6px; + border-spacing: 0; +} + +.nvtooltip table td { + padding: 2px 9px 2px 0; + vertical-align: middle; +} + +.nvtooltip table td.key { + font-weight: normal; +} + +.nvtooltip table td.key.total { + font-weight: bold; +} + +.nvtooltip table td.value { + text-align: right; + font-weight: bold; +} + +.nvtooltip table td.percent { + color: darkgray; +} + +.nvtooltip table tr.highlight td { + padding: 1px 9px 1px 0; + border-bottom-style: solid; + border-bottom-width: 1px; + border-top-style: solid; + border-top-width: 1px; +} + +.nvtooltip table td.legend-color-guide div { + width: 8px; + height: 8px; + vertical-align: middle; +} + +.nvtooltip table td.legend-color-guide div { + width: 12px; + height: 12px; + border: 1px solid #999; +} + +.nvtooltip .footer { + padding: 3px; + text-align: center; +} + +.nvtooltip-pending-removal { + pointer-events: none; + display: none; +} + +/**** +Interactive Layer +*/ +.nvd3 .nv-interactiveGuideLine { + pointer-events: none; +} + +.nvd3 line.nv-guideline { + stroke: #ccc; +} diff --git a/services/web/frontend/stylesheets/components/nvd3_override.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss similarity index 74% rename from services/web/frontend/stylesheets/components/nvd3_override.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss index 929a99e9db..72c3e2f99a 100644 --- a/services/web/frontend/stylesheets/components/nvd3_override.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss @@ -5,12 +5,9 @@ opacity: 0; } } + path.domain { opacity: 0; } } } - -svg.nvd3-iddle { - &:extend(svg.nvd3-svg); -} diff --git a/services/web/frontend/stylesheets/app/publisher-hub.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss similarity index 52% rename from services/web/frontend/stylesheets/app/publisher-hub.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss index 8d7e5ea7eb..f59b33e6ef 100644 --- a/services/web/frontend/stylesheets/app/publisher-hub.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss @@ -2,48 +2,66 @@ .recent-activity { .hub-big-number { text-align: right; - padding-right: 15px; + padding-right: var(--spacing-06); } } #templates-container { width: 100%; + tr { - border: 1px solid @ol-blue-gray-0; + border: 1px solid var(--bg-light-secondary); } + td { - padding: 15px; + padding: var(--spacing-06); } + td:last-child { text-align: right; } + .title-cell { max-width: 300px; } + .title-text { font-weight: bold; } + .hub-big-number { width: 60%; - padding-right: 10px; - padding-top: 10px; + padding-right: var(--spacing-04); + padding-top: var(--spacing-04); text-align: right; } + .hub-number-label, .since { width: 35%; float: right; - @media screen and (max-width: 940px) { + + @include media-breakpoint-down(md) { float: none; } } + .hub-long-big-number { - padding-right: 40px; + padding-right: var(--spacing-10); } + .created-on { - color: @gray-light; + @include body-sm; + + color: var(--content-disabled); font-style: italic; - font-size: 14px; } } + + .overbox { + margin: 0; + padding: var(--spacing-10) var(--spacing-07); + background: var(--white); + border: 1px solid var(--content-disabled); + } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss index e2c807e928..a4bfa532e3 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -91,3 +91,8 @@ color: var(--yellow-50); } } + +.admin-page summary { + // firefox does not show markers for block items + display: list-item; +} 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 83b6fbd28a..1bf487eeca 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -85,7 +85,7 @@ } &:hover { - background-color: $bg-light-secondary; + background-color: var(--bg-light-secondary); } .welcome-message-card-img { diff --git a/services/web/frontend/stylesheets/components/daterange-picker.less b/services/web/frontend/stylesheets/components/daterange-picker.less deleted file mode 100644 index 43e0e3ba55..0000000000 --- a/services/web/frontend/stylesheets/components/daterange-picker.less +++ /dev/null @@ -1,656 +0,0 @@ -// -// A stylesheet for use with Bootstrap 3.x -// @author: Dan Grossman http://www.dangrossman.info/ -// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. -// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php -// @website: https://www.improvely.com/ -// - -// -// VARIABLES -// - -// -// Settings - -// The class name to contain everything within. -@arrow-size: 7px; - -// -// Colors -@daterangepicker-color: @brand-primary; -@daterangepicker-bg-color: #fff; - -@daterangepicker-cell-color: @daterangepicker-color; -@daterangepicker-cell-border-color: transparent; -@daterangepicker-cell-bg-color: @daterangepicker-bg-color; - -@daterangepicker-cell-hover-color: @daterangepicker-color; -@daterangepicker-cell-hover-border-color: @daterangepicker-cell-border-color; -@daterangepicker-cell-hover-bg-color: #eee; - -@daterangepicker-in-range-color: #000; -@daterangepicker-in-range-border-color: transparent; -@daterangepicker-in-range-bg-color: #ebf4f8; - -@daterangepicker-active-color: #fff; -@daterangepicker-active-bg-color: #138a07; -@daterangepicker-active-border-color: transparent; - -@daterangepicker-unselected-color: #999; -@daterangepicker-unselected-border-color: transparent; -@daterangepicker-unselected-bg-color: #fff; - -// -// daterangepicker -@daterangepicker-width: 278px; -@daterangepicker-padding: 4px; -@daterangepicker-z-index: 3000; - -@daterangepicker-border-size: 1px; -@daterangepicker-border-color: #ccc; -@daterangepicker-border-radius: 4px; - -// -// Calendar -@daterangepicker-calendar-margin: @daterangepicker-padding; -@daterangepicker-calendar-bg-color: @daterangepicker-bg-color; - -@daterangepicker-calendar-border-size: 1px; -@daterangepicker-calendar-border-color: @daterangepicker-bg-color; -@daterangepicker-calendar-border-radius: @daterangepicker-border-radius; - -// -// Calendar Cells -@daterangepicker-cell-size: 20px; -@daterangepicker-cell-width: @daterangepicker-cell-size; -@daterangepicker-cell-height: @daterangepicker-cell-size; - -@daterangepicker-cell-border-radius: @daterangepicker-calendar-border-radius; -@daterangepicker-cell-border-size: 1px; - -// -// Dropdowns -@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1; - -// -// Controls -@daterangepicker-control-height: 30px; -@daterangepicker-control-line-height: @daterangepicker-control-height; -@daterangepicker-control-color: #555; - -@daterangepicker-control-border-size: 1px; -@daterangepicker-control-border-color: #ccc; -@daterangepicker-control-border-radius: 4px; - -@daterangepicker-control-active-border-size: 1px; -@daterangepicker-control-active-border-color: @brand-primary; -@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius; - -@daterangepicker-control-disabled-color: #ccc; - -// -// Ranges -@daterangepicker-ranges-color: @brand-primary; -@daterangepicker-ranges-bg-color: daterangepicker-ranges-color; - -@daterangepicker-ranges-border-size: 1px; -@daterangepicker-ranges-border-color: @daterangepicker-ranges-bg-color; -@daterangepicker-ranges-border-radius: @daterangepicker-border-radius; - -@daterangepicker-ranges-hover-color: #fff; -@daterangepicker-ranges-hover-bg-color: @daterangepicker-ranges-color; -@daterangepicker-ranges-hover-border-size: @daterangepicker-ranges-border-size; -@daterangepicker-ranges-hover-border-color: @daterangepicker-ranges-hover-bg-color; -@daterangepicker-ranges-hover-border-radius: @daterangepicker-border-radius; - -@daterangepicker-ranges-active-border-size: @daterangepicker-ranges-border-size; -@daterangepicker-ranges-active-border-color: @daterangepicker-ranges-bg-color; -@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius; - -// -// STYLESHEETS -// -.daterangepicker { - position: absolute; - color: @daterangepicker-color; - background-color: @daterangepicker-bg-color; - border-radius: @daterangepicker-border-radius; - width: @daterangepicker-width; - padding: @daterangepicker-padding; - margin-top: @daterangepicker-border-size; - - // TODO: Should these be parameterized?? - // top: 100px; - // left: 20px; - - @arrow-prefix-size: @arrow-size; - @arrow-suffix-size: (@arrow-size - @daterangepicker-border-size); - - &:before, - &:after { - position: absolute; - display: inline-block; - - border-bottom-color: rgba(0, 0, 0, 0.2); - content: ''; - } - - &:before { - top: -@arrow-prefix-size; - - border-right: @arrow-prefix-size solid transparent; - border-left: @arrow-prefix-size solid transparent; - border-bottom: @arrow-prefix-size solid @daterangepicker-border-color; - } - - &:after { - top: -@arrow-suffix-size; - - border-right: @arrow-suffix-size solid transparent; - border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color; - border-left: @arrow-suffix-size solid transparent; - } - - &.opensleft { - &:before { - // TODO: Make this relative to prefix size. - right: @arrow-prefix-size + 2px; - } - - &:after { - // TODO: Make this relative to suffix size. - right: @arrow-suffix-size + 4px; - } - } - - &.openscenter { - &:before { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - - &:after { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - } - - &.opensright { - &:before { - // TODO: Make this relative to prefix size. - left: @arrow-prefix-size + 2px; - } - - &:after { - // TODO: Make this relative to suffix size. - left: @arrow-suffix-size + 4px; - } - } - - &.dropup { - margin-top: -5px; - - // NOTE: Note sure why these are special-cased. - &:before { - top: initial; - bottom: -@arrow-prefix-size; - border-bottom: initial; - border-top: @arrow-prefix-size solid @daterangepicker-border-color; - } - - &:after { - top: initial; - bottom: -@arrow-suffix-size; - border-bottom: initial; - border-top: @arrow-suffix-size solid @daterangepicker-bg-color; - } - } - - &.dropdown-menu { - max-width: none; - z-index: @daterangepicker-dropdown-z-index; - } - - &.single { - .ranges, - .calendar { - float: none; - } - } - - /* Calendars */ - &.show-calendar { - .calendar { - display: block; - } - } - - .calendar { - display: none; - max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2); - margin: @daterangepicker-calendar-margin; - - &.single { - .calendar-table { - border: none; - } - } - - th, - td { - white-space: nowrap; - text-align: center; - - // TODO: Should this actually be hard-coded? - min-width: 32px; - } - } - - .calendar-table { - border: @daterangepicker-calendar-border-size solid - @daterangepicker-calendar-border-color; - padding: @daterangepicker-calendar-margin; - border-radius: @daterangepicker-calendar-border-radius; - background-color: @daterangepicker-calendar-bg-color; - } - - table { - width: 100%; - margin: 0; - } - - td, - th { - text-align: center; - width: @daterangepicker-cell-width; - height: @daterangepicker-cell-height; - border-radius: @daterangepicker-cell-border-radius; - border: @daterangepicker-cell-border-size solid - @daterangepicker-cell-border-color; - white-space: nowrap; - cursor: pointer; - - &.available { - &:hover { - background-color: @daterangepicker-cell-hover-bg-color; - border-color: @daterangepicker-cell-hover-border-color; - color: @daterangepicker-cell-hover-color; - } - } - - &.week { - font-size: 80%; - color: #ccc; - } - } - - td { - &.off { - &, - &.in-range, - &.start-date, - &.end-date { - background-color: @daterangepicker-unselected-bg-color; - border-color: @daterangepicker-unselected-border-color; - color: @daterangepicker-unselected-color; - } - } - - // - // Date Range - &.in-range { - background-color: @daterangepicker-in-range-bg-color; - border-color: @daterangepicker-in-range-border-color; - color: @daterangepicker-in-range-color; - - // TODO: Should this be static or should it be parameterized? - border-radius: 0; - } - - &.start-date { - border-radius: @daterangepicker-cell-border-radius 0 0 - @daterangepicker-cell-border-radius; - } - - &.end-date { - border-radius: 0 @daterangepicker-cell-border-radius - @daterangepicker-cell-border-radius 0; - } - - &.start-date.end-date { - border-radius: @daterangepicker-cell-border-radius; - } - - &.active { - &, - &:hover { - background-color: @daterangepicker-active-bg-color; - border-color: @daterangepicker-active-border-color; - color: @daterangepicker-active-color; - } - } - } - - th { - &.month { - width: auto; - } - } - - // - // Disabled Controls - // - td, - option { - &.disabled { - color: #999; - cursor: not-allowed; - text-decoration: line-through; - } - } - - select { - &.monthselect, - &.yearselect { - font-size: 12px; - padding: 1px; - height: auto; - margin: 0; - cursor: default; - } - - &.monthselect { - margin-right: 2%; - width: 56%; - } - - &.yearselect { - width: 40%; - } - - &.hourselect, - &.minuteselect, - &.secondselect, - &.ampmselect { - width: 50px; - margin-bottom: 0; - } - } - - // - // Text Input Controls (above calendar) - // - .input-mini { - border: @daterangepicker-control-border-size solid - @daterangepicker-control-border-color; - border-radius: @daterangepicker-control-border-radius; - color: @daterangepicker-control-color; - height: @daterangepicker-control-line-height; - line-height: @daterangepicker-control-height; - display: block; - vertical-align: middle; - - // TODO: Should these all be static, too?? - margin: 0 0 5px 0; - padding: 0 6px 0 28px; - width: 100%; - - &.active { - border: @daterangepicker-control-active-border-size solid - @daterangepicker-control-active-border-color; - border-radius: @daterangepicker-control-active-border-radius; - } - } - - .daterangepicker_input { - position: relative; - padding-left: 0; - - i { - position: absolute; - - // NOTE: These appear to be eyeballed to me... - left: 8px; - top: 10px; - } - } - &.rtl { - .input-mini { - padding-right: 28px; - padding-left: 6px; - } - .daterangepicker_input i { - left: auto; - right: 8px; - } - } - - // - // Time Picker - // - .calendar-time { - text-align: center; - margin: 5px auto; - line-height: @daterangepicker-control-line-height; - position: relative; - padding-left: 28px; - - select { - &.disabled { - color: @daterangepicker-control-disabled-color; - cursor: not-allowed; - } - } - } -} - -// -// Predefined Ranges -// - -.ranges { - font-size: 11px; - float: none; - margin: 4px; - text-align: left; - - ul { - list-style: none; - margin: 0 auto; - padding: 0; - width: 100%; - } - - li { - font-size: 13px; - background-color: @daterangepicker-ranges-bg-color; - border: @daterangepicker-ranges-border-size solid - @daterangepicker-ranges-border-color; - border-radius: @daterangepicker-ranges-border-radius; - color: @daterangepicker-ranges-color; - padding: 3px 12px; - margin-bottom: 8px; - cursor: pointer; - - &:hover { - background-color: @daterangepicker-ranges-hover-bg-color; - color: @daterangepicker-ranges-hover-color; - } - - &.active { - background-color: @daterangepicker-ranges-hover-bg-color; - border: @daterangepicker-ranges-hover-border-size solid - @daterangepicker-ranges-hover-border-color; - color: @daterangepicker-ranges-hover-color; - } - } -} - -/* Larger Screen Styling */ -@media (min-width: 564px) { - .daterangepicker { - .glyphicon { - font-family: FontAwesome; - } - .glyphicon-chevron-left:before { - content: '\f053'; - } - .glyphicon-chevron-right:before { - content: '\f054'; - } - .glyphicon-calendar:before { - content: '\f073'; - } - - width: auto; - - .ranges { - ul { - width: 160px; - } - } - - &.single { - .ranges { - ul { - width: 100%; - } - } - - .calendar.left { - clear: none; - } - - &.ltr { - .ranges, - .calendar { - float: left; - } - } - &.rtl { - .ranges, - .calendar { - float: right; - } - } - } - - &.ltr { - direction: ltr; - text-align: left; - .calendar { - &.left { - clear: left; - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - - &.right { - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-right: 12px; - } - - .calendar.left .calendar-table { - padding-right: 12px; - } - - .ranges, - .calendar { - float: left; - } - } - &.rtl { - direction: rtl; - text-align: right; - .calendar { - &.left { - clear: right; - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - - &.right { - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-left: 12px; - } - - .calendar.left .calendar-table { - padding-left: 12px; - } - - .ranges, - .calendar { - text-align: right; - float: right; - } - } - } -} - -@media (min-width: 730px) { - /* force the calendar to display on one row */ - &.show-calendar { - min-width: 658px; /* width of all contained elements, IE/Edge fallback */ - width: -moz-max-content; - width: -webkit-max-content; - width: max-content; - } - - .daterangepicker { - .ranges { - width: auto; - } - &.ltr { - .ranges { - float: left; - clear: none !important; - } - } - &.rtl { - .ranges { - float: right; - } - } - - .calendar { - clear: none !important; - } - } -} diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index d42a2ab502..fd8c308117 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -61,8 +61,6 @@ @import 'components/hover.less'; @import 'components/ui-select.less'; @import 'components/input-suggestions.less'; -@import 'components/nvd3.less'; -@import 'components/nvd3_override.less'; @import 'components/infinite-scroll.less'; @import 'components/expand-collapse.less'; @import 'components/beta-badges.less'; @@ -82,7 +80,6 @@ @import 'components/modals.less'; @import 'components/tooltip.less'; @import 'components/popovers.less'; -@import 'components/daterange-picker'; @import 'components/lists.less'; @import 'components/overbox.less'; @import 'components/embed-responsive.less'; @@ -118,7 +115,6 @@ @import 'app/invite.less'; @import 'app/error-pages.less'; @import 'app/editor/history-v2.less'; -@import 'app/metrics.less'; @import 'app/open-in-overleaf.less'; @import 'app/primary-email-check'; @import 'app/grammarly'; @@ -126,9 +122,6 @@ @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; -@import 'app/institution-hub.less'; -@import 'app/publisher-hub.less'; -@import 'app/admin-hub.less'; @import 'app/import.less'; @import 'app/website-redesign.less'; @import 'app/add-secondary-email-prompt.less'; From 25d397281024578f3c230a3e7593040193859f21 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:40 +0200 Subject: [PATCH 026/209] [web] Migrate post-gateway.pug to BS5 (#25860) * Remove `data-ol-auto-submit`, to test the page * Migrate post-gateway.pug to BS5 * Revert "Remove `data-ol-auto-submit`, to test the page" This reverts commit ee728b0bdda80d739bd09b2e4e9419303f7053db. * Fix breakbpoints * Use `layout-marketing` GitOrigin-RevId: 73aa4da1e4ddae03d9c8e6671c6a8ccb89ecf0b0 --- services/web/app/views/general/post-gateway.pug | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index dcc844171a..b17e61cb41 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -5,15 +5,15 @@ block vars - var suppressFooter = true - var suppressSkipToContent = true - var suppressCookieBanner = true - - bootstrap5PageStatus = 'disabled' block content .content.content-alt .container .row - .col-md-6.col-md-offset-3 + .col-lg-6.offset-lg-3 .card - p.text-center #{translate('processing_your_request')} + .card-body + p.text-center #{translate('processing_your_request')} form( data-ol-regular-form From a210a7b14d720c6a73a8b741b765952d00dd2bdd Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:53 +0200 Subject: [PATCH 027/209] [web] Migrate general Pug pages to BS5 (#25937) * Revert me! Temporarily update code to test updates * Update layout-no-js.pug to use BS5 * Migrate pages to BS5 * Revert "Revert me! Temporarily update code to test updates" This reverts commit 03d980939dcbdc3f73ddf1e673acbc3fbfdfe2ec. * Use `.error-container` class instead of BS5 utility * Fix breakbpoints * Use `.error-container` instead of utility class GitOrigin-RevId: fd39c4f7278f175bbdeee24826f7a2226b1d7c70 --- services/web/app/views/general/400.pug | 43 +++++------ services/web/app/views/general/404.pug | 15 ++-- services/web/app/views/general/500.pug | 34 ++++----- services/web/app/views/general/closed.pug | 8 +- .../app/views/general/unsupported-browser.pug | 74 +++++++++---------- .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/error-pages.scss | 7 ++ 8 files changed, 92 insertions(+), 92 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 9fc97823c5..26aeeb778a 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,30 +2,27 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - p.error-status Something went wrong, sorry. - p.error-description - | There was a problem with your request. - if(message) - | - | The error is: + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Something went wrong, sorry. + p.fs-5 + | There was a problem with your request. if(message) - p.error-box - | #{message} - p.error-description - | Please go back and try again. - | If the problem persists, please contact us at | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.error-actions - a.error-btn(href="javascript:history.back()") Back - |   - a.btn.btn-secondary(href="/") Home + | The error is: + if(message) + p.bg-light.p-3.border-2.font-monospace + | #{message} + p.fs-5 + | Please go back and try again. + | If the problem persists, please contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.mt-5.d-flex.gap-3 + a.btn.btn-primary(href="javascript:history.back()") Back + a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index f4b5800cf2..42b0a44932 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,14 +1,13 @@ -extends ../layout-marketing +extends ../layout-react -block vars - - bootstrap5PageStatus = 'disabled' +block append meta + meta(name="ol-user" data-type="json" content=user) block content main.content.content-alt#main-content .container .error-container - .error-details - p.error-status Not found - p.error-description #{translate("cant_find_page")} - p.error-actions - a.error-btn(href="/") Home + h1.mb-4 Not found + p.fs-5 #{translate("cant_find_page")} + p.mt-5 + a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 90cb1e3606..32dfb27a58 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,23 +2,21 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - p.error-status Something went wrong, sorry. - p.error-description Our staff are probably looking into this, but if it continues, please check our status page at - | - | - a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} - | - | or contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.error-actions - a.error-btn(href="/") Home + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Something went wrong, sorry. + p.fs-5 Our staff are probably looking into this, but if it continues, please check our status page at + | + | + a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} + | + | or contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.mt-5 + a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index f4012997bd..0d10a84476 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,13 @@ -extends ../layout-marketing +extends ../layout-react -block vars - - bootstrap5PageStatus = 'disabled' +block append meta + meta(name="ol-user" data-type="json" content=user) block content main.content#main-content .container .row - .col-md-8.col-md-offset-2.text-center + .col-lg-8.offset-lg-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index f8806cf8d2..84e1f8f55e 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,45 +2,43 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - h1.error-status Unsupported Browser - p.error-description - | Sorry, we don't support your browser anymore. Please see below what browsers we support. - br - | If you think you're seeing this message in error, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . - - if fromURL - p - | URL: - | - a(href=fromURL) #{fromURL} - - hr - + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Unsupported Browser + p.fs-5 + | Sorry, we don't support your browser anymore. Please see below what browsers we support. + br + | If you think you're seeing this message in error, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . + + if fromURL p - | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. - br - | Firefox ESR is also supported for 12 months. - p - | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | URL: | - a(href="mailto:" + settings.adminEmail) get in touch - | - | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. - p - strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. - p - | If you cannot upgrade to one of the supported browsers, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . + a(href=fromURL) #{fromURL} + + hr + + p + | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. + br + | Firefox ESR is also supported for 12 months. + p + | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | + a(href="mailto:" + settings.adminEmail) get in touch + | + | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. + p + strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. + p + | If you cannot upgrade to one of the supported browsers, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index c86721a810..b5bf3cc434 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath()) + link(rel="stylesheet", href=buildCssPath('', 5)) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index a3adc98819..f10f00842d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,6 +37,7 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; +@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss new file mode 100644 index 0000000000..e68f675aa0 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss @@ -0,0 +1,7 @@ +.error-container { + padding: var(--spacing-08); + + @include media-breakpoint-up(lg) { + padding: var(--spacing-08) var(--spacing-11); + } +} From 2226594ade459828e727c143606246a0e0107c48 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:33:09 +0200 Subject: [PATCH 028/209] [web] Migrate 4 simple user pages to BS5 (#25947) * Migrate email-preferences.pug to BS5 https://www.dev-overleaf.com/user/email-preferences * Migrate sessions.pug to BS5 https://www.dev-overleaf.com/user/sessions * Migrate one_time_login.pug to BS5 https://www.dev-overleaf.com/read-only/one-time-login * Fix positions in back-to-btns mixin * Migrate accountSuspended.pug to BS5 https://www.dev-overleaf.com/account-suspended * Set max-width of 400px in account-suspended page * Fix column widths in sessions.pug GitOrigin-RevId: 8ec6100fb230cf532049fcc9aba7c00def20ea0e --- .../web/app/views/_mixins/back_to_btns.pug | 6 +- .../web/app/views/user/accountSuspended.pug | 8 +- .../web/app/views/user/email-preferences.pug | 80 ++++++------ .../web/app/views/user/one_time_login.pug | 24 ++-- services/web/app/views/user/sessions.pug | 116 +++++++++--------- .../stylesheets/bootstrap-5/base/layout.scss | 4 + 6 files changed, 118 insertions(+), 120 deletions(-) diff --git a/services/web/app/views/_mixins/back_to_btns.pug b/services/web/app/views/_mixins/back_to_btns.pug index 570237b5bc..287a76acd7 100644 --- a/services/web/app/views/_mixins/back_to_btns.pug +++ b/services/web/app/views/_mixins/back_to_btns.pug @@ -1,4 +1,4 @@ mixin back-to-btns(settingsAnchor) - a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} - | - a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} + .d-flex.gap-3 + a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/app/views/user/accountSuspended.pug b/services/web/app/views/user/accountSuspended.pug index da57f4d9ff..7231713416 100644 --- a/services/web/app/views/user/accountSuspended.pug +++ b/services/web/app/views/user/accountSuspended.pug @@ -4,12 +4,12 @@ block vars - var suppressNavbar = true - var suppressFooter = true - metadata.robotsNoindexNofollow = true - - bootstrap5PageStatus = 'disabled' block content main.content.content-alt#main-content .container-custom-sm.mx-auto .card - h3 #{translate('your_account_is_suspended')} - p #{translate('sorry_this_account_has_been_suspended')} - p !{translate('please_contact_us_if_you_think_this_is_in_error', {}, [{name: 'a', attrs: {href: `mailto:${settings.adminEmail}`}}])} + .card-body + h3 #{translate('your_account_is_suspended')} + p #{translate('sorry_this_account_has_been_suspended')} + p !{translate('please_contact_us_if_you_think_this_is_in_error', {}, [{name: 'a', attrs: {href: `mailto:${settings.adminEmail}`}}])} diff --git a/services/web/app/views/user/email-preferences.pug b/services/web/app/views/user/email-preferences.pug index 465ffede37..86ebc5f841 100644 --- a/services/web/app/views/user/email-preferences.pug +++ b/services/web/app/views/user/email-preferences.pug @@ -1,49 +1,47 @@ extends ../layout-marketing include ../_mixins/back_to_btns -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card - .page-header - h1 #{translate("newsletter_info_title")} - - p #{translate("newsletter_info_summary")} - - - var submitAction - if subscribed - - submitAction = '/user/newsletter/unsubscribe' - p !{translate("newsletter_info_subscribed", {}, ['strong'])} - else - - submitAction = '/user/newsletter/subscribe' - p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} - - form( - data-ol-async-form - data-ol-reload-on-success - name="newsletterForm" - action=submitAction - method="POST" - ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() - p.actions.text-center - if subscribed - button.btn-danger.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("unsubscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… - else - button.btn-primary.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("subscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… - - if subscribed - p #{translate("newsletter_info_note")} - - .page-separator - +back-to-btns() + .card-body + .page-header + h1 #{translate("newsletter_info_title")} + + p #{translate("newsletter_info_summary")} + + - var submitAction + if subscribed + - submitAction = '/user/newsletter/unsubscribe' + p !{translate("newsletter_info_subscribed", {}, ['strong'])} + else + - submitAction = '/user/newsletter/subscribe' + p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} + + form( + data-ol-async-form + data-ol-reload-on-success + name="newsletterForm" + action=submitAction + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + p.actions.text-center + if subscribed + button.btn-danger.btn(type='submit', data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("unsubscribe")} + span(hidden data-ol-inflight="pending") #{translate("saving")}… + else + button.btn-primary.btn(type='submit', data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("subscribe")} + span(hidden data-ol-inflight="pending") #{translate("saving")}… + + if subscribed + p #{translate("newsletter_info_note")} + + .page-separator + +back-to-btns() diff --git a/services/web/app/views/user/one_time_login.pug b/services/web/app/views/user/one_time_login.pug index 89e1491913..648f6d93c1 100644 --- a/services/web/app/views/user/one_time_login.pug +++ b/services/web/app/views/user/one_time_login.pug @@ -1,20 +1,18 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .col-lg-6.offset-lg-3.col-xl-4.offset-xl-4 .card - .page-header - h1 We're back! - p Overleaf is now running normally. - p - | Please - | - a(href="/login") log in - | - | to continue working on your projects. + .card-body + .page-header + h1 We're back! + p Overleaf is now running normally. + p + | Please + | + a(href="/login") log in + | + | to continue working on your projects. diff --git a/services/web/app/views/user/sessions.pug b/services/web/app/views/user/sessions.pug index 187c1dae75..ffd65a3548 100644 --- a/services/web/app/views/user/sessions.pug +++ b/services/web/app/views/user/sessions.pug @@ -1,72 +1,70 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card.clear-user-sessions - .page-header - h1 #{translate("your_sessions")} - - if currentSession.ip_address && currentSession.session_created - h3 #{translate("current_session")} - div - table.table.table-striped - thead - tr - th #{translate("ip_address")} - th #{translate("session_created_at")} - tr - td #{currentSession.ip_address} - td #{moment(currentSession.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - - h3 #{translate("other_sessions")} - div - p.small - | !{translate("clear_sessions_description")} - - form( - data-ol-async-form - action='/user/sessions/clear' - method='POST' - ) - input(name='_csrf' type='hidden' value=csrfToken) - div(data-ol-not-sent) - if sessions.length == 0 - p.text-center - | #{translate("no_other_sessions")} - - if sessions.length > 0 + .card-body + .page-header + h1 #{translate("your_sessions")} + + if currentSession.ip_address && currentSession.session_created + h3 #{translate("current_session")} + div table.table.table-striped thead tr th #{translate("ip_address")} th #{translate("session_created_at")} - for session in sessions tr - td #{session.ip_address} - td #{moment(session.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - - p.actions - .text-center - button.btn.btn-lg.btn-primary( - type="submit" - data-ol-disable-inflight - ) - span(data-ol-inflight="idle") #{translate('clear_sessions')} - span(hidden data-ol-inflight="pending") #{translate("processing")}… - - div(hidden data-ol-sent) - p.text-center - | #{translate("no_other_sessions")} - - p.text-success.text-center - | #{translate('clear_sessions_success')} - .page-separator - a.btn.btn-secondary(href='/user/settings') #{translate('back_to_account_settings')} - | - a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} + td #{currentSession.ip_address} + td #{moment(currentSession.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC + + h3 #{translate("other_sessions")} + div + p.small + | !{translate("clear_sessions_description")} + + form( + data-ol-async-form + action='/user/sessions/clear' + method='POST' + ) + input(name='_csrf' type='hidden' value=csrfToken) + div(data-ol-not-sent) + if sessions.length == 0 + p.text-center + | #{translate("no_other_sessions")} + + if sessions.length > 0 + table.table.table-striped + thead + tr + th #{translate("ip_address")} + th #{translate("session_created_at")} + for session in sessions + tr + td #{session.ip_address} + td #{moment(session.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC + + p.actions + .text-center + button.btn.btn-lg.btn-primary( + type="submit" + data-ol-disable-inflight + ) + span(data-ol-inflight="idle") #{translate('clear_sessions')} + span(hidden data-ol-inflight="pending") #{translate("processing")}… + + div(hidden data-ol-sent) + p.text-center + | #{translate("no_other_sessions")} + + p.text-success.text-center + | #{translate('clear_sessions_success')} + .page-separator + .d-flex.gap-3 + a.btn.btn-secondary(href='/user/settings') #{translate('back_to_account_settings')} + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index 650bdc727f..0733a04304 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -87,3 +87,7 @@ hr { text-decoration: none; } } + +.container-custom-sm { + max-width: 400px; +} From 385f5706d869d86b5d765d28a139e344384f7d85 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Tue, 3 Jun 2025 12:38:04 +0100 Subject: [PATCH 029/209] Add doc and file counts to the admin info page for a project (#26076) GitOrigin-RevId: afa7fa4e562962a4c7c88f6d3d5f13c0f1feb2e3 --- services/web/frontend/js/utils/meta.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6e15309187..2e8df94273 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -190,6 +190,7 @@ export interface Meta { 'ol-preventCompileOnLoad'?: boolean 'ol-primaryEmail': { email: string; confirmed: boolean } 'ol-project': any // TODO + 'ol-projectEntityCounts'?: { files: number; docs: number } 'ol-projectHistoryBlobsEnabled': boolean 'ol-projectName': string 'ol-projectOwnerHasPremiumOnPageLoad': boolean From d5ba2e3f1c96452085fe4467bf4c18d48801b45f Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 3 Jun 2025 12:39:43 +0100 Subject: [PATCH 030/209] Merge pull request #26094 from overleaf/mj-ide-fps-update [web] Add full project search to redesign switcher modal GitOrigin-RevId: 3f494ddc3bf94d9f7c2d6de62183b1805b110601 --- services/web/frontend/extracted-translations.json | 1 + .../js/features/ide-redesign/components/switcher-modal/modal.tsx | 1 + services/web/locales/en.json | 1 + 3 files changed, 3 insertions(+) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 506a5bb5f8..e5bb2fced3 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1483,6 +1483,7 @@ "search_whole_word": "", "search_within_selection": "", "searched_path_for_lines_containing": "", + "searching_all_project_files_is_now_available": "", "security": "", "see_suggestions_from_collaborators": "", "select_a_column_or_a_merged_cell_to_align": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx index 6942674de5..2bb724c8a4 100644 --- a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx @@ -160,6 +160,7 @@ const SwitcherWhatsNew = () => {

{t('latest_updates')}

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

diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bdebf3d289..a903e6a26b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1946,6 +1946,7 @@ "search_whole_word": "Whole word", "search_within_selection": "Within selection", "searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"", + "searching_all_project_files_is_now_available": "Searching all project files is now available (2 June 2025)", "secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.", "security": "Security", "see_suggestions_from_collaborators": "See suggestions from collaborators", From b84d23564bef3709f528173091530f2264148a00 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 3 Jun 2025 13:41:12 +0200 Subject: [PATCH 031/209] [web] remove spurious cleanup of project audit log entries (#26102) GitOrigin-RevId: 32693f89b417b357588d059500ab51c3a9dd46dd --- services/web/app/src/Features/Project/ProjectDeleter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index e5764bab86..2bb8dd0b1f 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -343,7 +343,6 @@ async function expireDeletedProject(projectId) { await DeletedProject.deleteOne({ 'deleterData.deletedProjectId': projectId, }) - await ProjectAuditLogEntry.deleteMany({ projectId }) return } const deletedProject = await DeletedProject.findOne({ From edacb9ec0b2a79e04676488d6a5d7ff5e72b44cd Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:57:29 +0200 Subject: [PATCH 032/209] Merge pull request #26111 from overleaf/revert-25937-ac-bs5-general-pug-pages Revert "[web] Migrate general Pug pages to BS5" GitOrigin-RevId: fcc42ee28004aa55c09ecbd5f5e96c6067e717e9 --- services/web/app/views/general/400.pug | 43 ++++++----- services/web/app/views/general/404.pug | 15 ++-- services/web/app/views/general/500.pug | 34 +++++---- services/web/app/views/general/closed.pug | 8 +- .../app/views/general/unsupported-browser.pug | 76 ++++++++++--------- .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/pages/all.scss | 1 - .../bootstrap-5/pages/error-pages.scss | 7 -- 8 files changed, 93 insertions(+), 93 deletions(-) delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 26aeeb778a..9fc97823c5 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,27 +2,30 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Something went wrong, sorry. - p.fs-5 - | There was a problem with your request. + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + p.error-status Something went wrong, sorry. + p.error-description + | There was a problem with your request. + if(message) + | + | The error is: if(message) + p.error-box + | #{message} + p.error-description + | Please go back and try again. + | If the problem persists, please contact us at | - | The error is: - if(message) - p.bg-light.p-3.border-2.font-monospace - | #{message} - p.fs-5 - | Please go back and try again. - | If the problem persists, please contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.mt-5.d-flex.gap-3 - a.btn.btn-primary(href="javascript:history.back()") Back - a.btn.btn-secondary(href="/") Home + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.error-actions + a.error-btn(href="javascript:history.back()") Back + |   + a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index 42b0a44932..f4b5800cf2 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,13 +1,14 @@ -extends ../layout-react +extends ../layout-marketing -block append meta - meta(name="ol-user" data-type="json" content=user) +block vars + - bootstrap5PageStatus = 'disabled' block content main.content.content-alt#main-content .container .error-container - h1.mb-4 Not found - p.fs-5 #{translate("cant_find_page")} - p.mt-5 - a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home + .error-details + p.error-status Not found + p.error-description #{translate("cant_find_page")} + p.error-actions + a.error-btn(href="/") Home diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 32dfb27a58..90cb1e3606 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,21 +2,23 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Something went wrong, sorry. - p.fs-5 Our staff are probably looking into this, but if it continues, please check our status page at - | - | - a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} - | - | or contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.mt-5 - a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + p.error-status Something went wrong, sorry. + p.error-description Our staff are probably looking into this, but if it continues, please check our status page at + | + | + a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} + | + | or contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.error-actions + a.error-btn(href="/") Home diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index 0d10a84476..f4012997bd 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,13 @@ -extends ../layout-react +extends ../layout-marketing -block append meta - meta(name="ol-user" data-type="json" content=user) +block vars + - bootstrap5PageStatus = 'disabled' block content main.content#main-content .container .row - .col-lg-8.offset-lg-2.text-center + .col-md-8.col-md-offset-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index 84e1f8f55e..f8806cf8d2 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,43 +2,45 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Unsupported Browser - p.fs-5 - | Sorry, we don't support your browser anymore. Please see below what browsers we support. - br - | If you think you're seeing this message in error, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . - - if fromURL - p - | URL: + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + h1.error-status Unsupported Browser + p.error-description + | Sorry, we don't support your browser anymore. Please see below what browsers we support. + br + | If you think you're seeing this message in error, | - a(href=fromURL) #{fromURL} - - hr - - p - | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. - br - | Firefox ESR is also supported for 12 months. - p - | Support for beta or developer-preview browser versions cannot be guaranteed. Please - | - a(href="mailto:" + settings.adminEmail) get in touch - | - | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. - p - strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. - p - | If you cannot upgrade to one of the supported browsers, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . + a(href="mailto:" + settings.adminEmail) please let us know + | . + + if fromURL + p + | URL: + | + a(href=fromURL) #{fromURL} + + hr + + p + | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. + br + | Firefox ESR is also supported for 12 months. + p + | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | + a(href="mailto:" + settings.adminEmail) get in touch + | + | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. + p + strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. + p + | If you cannot upgrade to one of the supported browsers, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index b5bf3cc434..c86721a810 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath('', 5)) + link(rel="stylesheet", href=buildCssPath()) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index f10f00842d..a3adc98819 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,7 +37,6 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; -@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss deleted file mode 100644 index e68f675aa0..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss +++ /dev/null @@ -1,7 +0,0 @@ -.error-container { - padding: var(--spacing-08); - - @include media-breakpoint-up(lg) { - padding: var(--spacing-08) var(--spacing-11); - } -} From 54c0eb7fdc09f4ab5465c89868b6fe58eafa2bf8 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 14:34:10 +0100 Subject: [PATCH 033/209] Merge pull request #25958 from overleaf/bg-history-redis-check-persisted-version-on-update prevent setPersistedVersion from setting an out of bounds version GitOrigin-RevId: 9561b7b96399bed901db5c2ac20a0cdbf4c67395 --- .../storage/lib/chunk_store/redis.js | 12 +++++++++ .../storage/chunk_store_redis_backend.test.js | 27 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js index 0ae7cee2e5..9163536342 100644 --- a/services/history-v1/storage/lib/chunk_store/redis.js +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -501,6 +501,11 @@ rclient.defineCommand('set_persisted_version', { return 'too_low' end + -- Refuse to set a persisted version that is higher than the head version + if newPersistedVersion > headVersion then + return 'too_high' + end + -- Set the persisted version redis.call('SET', persistedVersionKey, newPersistedVersion) @@ -541,6 +546,13 @@ async function setPersistedVersion(projectId, persistedVersion) { status, }) + if (status === 'too_high') { + throw new VersionOutOfBoundsError( + 'Persisted version cannot be higher than head version', + { projectId, persistedVersion } + ) + } + return status } catch (err) { metrics.inc('chunk_store.redis.set_persisted_version', 1, { diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js index 2b13343fc4..04d801c73d 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -714,10 +714,20 @@ describe('chunk buffer Redis backend', function () { }) it('should set the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 3) + const status = await redisBackend.setPersistedVersion(projectId, 3) + expect(status).to.equal('ok') const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(3) }) + + it('should refuse to set a persisted version greater than the head version', async function () { + await expect( + redisBackend.setPersistedVersion(projectId, 10) + ).to.be.rejectedWith(VersionOutOfBoundsError) + // Ensure persisted version remains unchanged + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.be.null + }) }) describe('when the persisted version is set', function () { @@ -730,13 +740,24 @@ describe('chunk buffer Redis backend', function () { }) it('should set the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 5) + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(5) }) it('should not decrease the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 2) + const status = await redisBackend.setPersistedVersion(projectId, 2) + expect(status).to.equal('too_low') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(3) + }) + + it('should refuse to set a persisted version greater than the head version', async function () { + await expect( + redisBackend.setPersistedVersion(projectId, 10) + ).to.be.rejectedWith(VersionOutOfBoundsError) + // Ensure persisted version remains unchanged const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(3) }) From ef810a9f3675cd2f73b902b91a49efe16e8560d5 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:40:36 -0700 Subject: [PATCH 034/209] Merge pull request #25967 from overleaf/mf-sync-email-update-to-stripe-account [web] Sync Stripe customer email when user update their primary email in account setting GitOrigin-RevId: a5f4b4e960d2c9d4ba96a2b3036329f4868e1bb8 --- .../Features/Subscription/RecurlyWrapper.js | 17 ++++++---- .../Subscription/SubscriptionController.js | 23 +++++++------ .../web/app/src/Features/User/UserUpdater.js | 7 ++-- .../SubscriptionControllerTests.js | 32 ++++++++++++------- .../test/unit/src/User/UserUpdaterTests.js | 21 ++++++------ 5 files changed, 58 insertions(+), 42 deletions(-) diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 2227597737..234f094ae0 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -11,22 +11,27 @@ const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') /** - * @param accountId - * @param newEmail + * Updates the email address of a Recurly account + * + * @param userId + * @param newAccountEmail - the new email address to set for the Recurly account */ -async function updateAccountEmailAddress(accountId, newEmail) { +async function updateAccountEmailAddress(userId, newAccountEmail) { const data = { - email: newEmail, + email: newAccountEmail, } let requestBody try { requestBody = RecurlyWrapper._buildXml('account', data) } catch (error) { - throw OError.tag(error, 'error building xml', { accountId, newEmail }) + throw OError.tag(error, 'error building xml', { + accountId: userId, + newEmail: newAccountEmail, + }) } const { body } = await RecurlyWrapper.promises.apiRequest({ - url: `accounts/${accountId}`, + url: `accounts/${userId}`, method: 'PUT', body: requestBody, }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index a38b41f628..bd60fdc099 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -535,18 +535,17 @@ function cancelPendingSubscriptionChange(req, res, next) { }) } -function updateAccountEmailAddress(req, res, next) { +async function updateAccountEmailAddress(req, res, next) { const user = SessionManager.getSessionUser(req.session) - RecurlyWrapper.updateAccountEmailAddress( - user._id, - user.email, - function (error) { - if (error) { - return next(error) - } - res.sendStatus(200) - } - ) + try { + await RecurlyWrapper.promises.updateAccountEmailAddress( + user._id, + user.email + ) + return res.sendStatus(200) + } catch (error) { + return next(error) + } } function reactivateSubscription(req, res, next) { @@ -859,7 +858,7 @@ module.exports = { cancelV1Subscription, previewSubscription: expressify(previewSubscription), cancelPendingSubscriptionChange, - updateAccountEmailAddress, + updateAccountEmailAddress: expressify(updateAccountEmailAddress), reactivateSubscription, recurlyCallback, extendTrial: expressify(extendTrial), diff --git a/services/web/app/src/Features/User/UserUpdater.js b/services/web/app/src/Features/User/UserUpdater.js index 627e73875d..f21ee9a1ed 100644 --- a/services/web/app/src/Features/User/UserUpdater.js +++ b/services/web/app/src/Features/User/UserUpdater.js @@ -11,7 +11,6 @@ const EmailHandler = require('../Email/EmailHandler') const EmailHelper = require('../Helpers/EmailHelper') const Errors = require('../Errors/Errors') const NewsletterManager = require('../Newsletter/NewsletterManager') -const RecurlyWrapper = require('../Subscription/RecurlyWrapper') const UserAuditLogHandler = require('./UserAuditLogHandler') const AnalyticsManager = require('../Analytics/AnalyticsManager') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') @@ -252,7 +251,11 @@ async function setDefaultEmailAddress( } try { - await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email) + await Modules.promises.hooks.fire( + 'updateAccountEmailAddress', + user._id, + email + ) } catch (error) { // errors are ignored } diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index b3ae6610e1..546f10f17b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -153,7 +153,9 @@ describe('SubscriptionController', function () { '@overleaf/settings': this.settings, '../User/UserGetter': this.UserGetter, './RecurlyWrapper': (this.RecurlyWrapper = { - updateAccountEmailAddress: sinon.stub().yields(), + promises: { + updateAccountEmailAddress: sinon.stub().resolves(), + }, }), './RecurlyEventHandler': { sendRecurlyAnalyticsEvent: sinon.stub().resolves(), @@ -309,31 +311,39 @@ describe('SubscriptionController', function () { }) describe('updateAccountEmailAddress via put', function () { - it('should send the user and subscriptionId to RecurlyWrapper', function () { + it('should send the user and subscriptionId to RecurlyWrapper', async function () { this.res.sendStatus = sinon.spy() - this.SubscriptionController.updateAccountEmailAddress(this.req, this.res) - this.RecurlyWrapper.updateAccountEmailAddress + await this.SubscriptionController.updateAccountEmailAddress( + this.req, + this.res + ) + this.RecurlyWrapper.promises.updateAccountEmailAddress .calledWith(this.user._id, this.user.email) .should.equal(true) }) - it('should respond with 200', function () { + it('should respond with 200', async function () { this.res.sendStatus = sinon.spy() - this.SubscriptionController.updateAccountEmailAddress(this.req, this.res) + await this.SubscriptionController.updateAccountEmailAddress( + this.req, + this.res + ) this.res.sendStatus.calledWith(200).should.equal(true) }) - it('should send the error to the next handler when updating recurly account email fails', function (done) { - this.RecurlyWrapper.updateAccountEmailAddress.yields(new Error()) + it('should send the error to the next handler when updating recurly account email fails', async function () { + this.RecurlyWrapper.promises.updateAccountEmailAddress.rejects( + new Error() + ) this.next = sinon.spy(error => { - expect(error).instanceOf(Error) - done() + expect(error).to.be.instanceOf(Error) }) - this.SubscriptionController.updateAccountEmailAddress( + await this.SubscriptionController.updateAccountEmailAddress( this.req, this.res, this.next ) + expect(this.next.calledOnce).to.be.true }) }) diff --git a/services/web/test/unit/src/User/UserUpdaterTests.js b/services/web/test/unit/src/User/UserUpdaterTests.js index 5832bc4656..2803e6d6f2 100644 --- a/services/web/test/unit/src/User/UserUpdaterTests.js +++ b/services/web/test/unit/src/User/UserUpdaterTests.js @@ -59,11 +59,6 @@ describe('UserUpdater', function () { changeEmail: sinon.stub().resolves(), }, } - this.RecurlyWrapper = { - promises: { - updateAccountEmailAddress: sinon.stub().resolves(), - }, - } this.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), } @@ -264,9 +259,11 @@ describe('UserUpdater', function () { expect( this.NewsletterManager.promises.changeEmail ).to.have.been.calledWith(this.user, this.newEmail) - expect( - this.RecurlyWrapper.promises.updateAccountEmailAddress - ).to.have.been.calledWith(this.user._id, this.newEmail) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.newEmail + ) }) it('validates email', async function () { @@ -615,9 +612,11 @@ describe('UserUpdater', function () { expect( this.NewsletterManager.promises.changeEmail ).to.have.been.calledWith(this.user, this.newEmail) - expect( - this.RecurlyWrapper.promises.updateAccountEmailAddress - ).to.have.been.calledWith(this.user._id, this.newEmail) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.newEmail + ) }) it('handles Mongo errors', async function () { From 832f9923b96565c2b4c671a137e1a5cc423f3715 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:40:51 -0700 Subject: [PATCH 035/209] Merge pull request #25998 from overleaf/mf-update-stripe-email-from-subscription-dashboard [web] Make user able to sync their email address in subscription dashboard for Stripe subscription GitOrigin-RevId: 9abdc0e18ebea29b18c2041130946b9e50fa43db --- .../Subscription/SubscriptionController.js | 3 ++- ...x => personal-subscription-sync-email.tsx} | 18 ++++++------- .../dashboard/personal-subscription.tsx | 4 +-- .../dashboard/personal-subscription.test.tsx | 4 ++- .../SubscriptionControllerTests.js | 25 +++++++++++++------ 5 files changed, 34 insertions(+), 20 deletions(-) rename services/web/frontend/js/features/subscription/components/dashboard/{personal-subscription-recurly-sync-email.tsx => personal-subscription-sync-email.tsx} (87%) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index bd60fdc099..4a69acf56d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -538,7 +538,8 @@ function cancelPendingSubscriptionChange(req, res, next) { async function updateAccountEmailAddress(req, res, next) { const user = SessionManager.getSessionUser(req.session) try { - await RecurlyWrapper.promises.updateAccountEmailAddress( + await Modules.promises.hooks.fire( + 'updateAccountEmailAddress', user._id, user.email ) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx similarity index 87% rename from services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx rename to services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx index c518b3ca8c..5f38a6be9e 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx @@ -7,23 +7,23 @@ import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' -function PersonalSubscriptionRecurlySyncEmail() { +function PersonalSubscriptionSyncEmail() { const { t } = useTranslation() const { personalSubscription } = useSubscriptionDashboardContext() const userEmail = getMeta('ol-usersEmail') const { isLoading, isSuccess, runAsync } = useAsync() + if (!personalSubscription || !('payment' in personalSubscription)) return null + + const accountEmail = personalSubscription.payment.accountEmail + + if (!userEmail || accountEmail === userEmail) return null + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() runAsync(postJSON('/user/subscription/account/email')) } - if (!personalSubscription || !('payment' in personalSubscription)) return null - - const recurlyEmail = personalSubscription.payment.accountEmail - - if (!userEmail || recurlyEmail === userEmail) return null - return ( <>
@@ -39,7 +39,7 @@ function PersonalSubscriptionRecurlySyncEmail() { , ]} // eslint-disable-line react/jsx-key - values={{ recurlyEmail, userEmail }} + values={{ recurlyEmail: accountEmail, userEmail }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }} /> @@ -64,4 +64,4 @@ function PersonalSubscriptionRecurlySyncEmail() { ) } -export default PersonalSubscriptionRecurlySyncEmail +export default PersonalSubscriptionSyncEmail diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx index 2173ea45d3..ce9bbf97ed 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx @@ -5,7 +5,7 @@ import { ActiveSubscriptionNew } from '@/features/subscription/components/dashbo import { CanceledSubscription } from './states/canceled' import { ExpiredSubscription } from './states/expired' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' -import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' +import PersonalSubscriptionSyncEmail from './personal-subscription-sync-email' import OLNotification from '@/features/ui/components/ol/ol-notification' import RedirectAlerts from './redirect-alerts' @@ -90,7 +90,7 @@ function PersonalSubscription() { /> )}
- + ) } diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx index 8edc881caa..a61c9fca7f 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx @@ -190,7 +190,9 @@ describe('', function () { }) it('shows different payment email address section', async function () { - fetchMock.post('/user/subscription/account/email', 200) + fetchMock.post('/user/subscription/account/email', { + status: 200, + }) const usersEmail = 'foo@example.com' renderWithSubscriptionDashContext(, { metaTags: [ diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 546f10f17b..879a31b917 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -311,15 +311,25 @@ describe('SubscriptionController', function () { }) describe('updateAccountEmailAddress via put', function () { - it('should send the user and subscriptionId to RecurlyWrapper', async function () { + beforeEach(function () { + this.req.body = { + account_email: 'current_account_email@overleaf.com', + } + }) + + it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function () { this.res.sendStatus = sinon.spy() + await this.SubscriptionController.updateAccountEmailAddress( this.req, this.res ) - this.RecurlyWrapper.promises.updateAccountEmailAddress - .calledWith(this.user._id, this.user.email) - .should.equal(true) + + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.user.email + ) }) it('should respond with 200', async function () { @@ -332,9 +342,10 @@ describe('SubscriptionController', function () { }) it('should send the error to the next handler when updating recurly account email fails', async function () { - this.RecurlyWrapper.promises.updateAccountEmailAddress.rejects( - new Error() - ) + this.Modules.promises.hooks.fire + .withArgs('updateAccountEmailAddress', this.user._id, this.user.email) + .rejects(new Error()) + this.next = sinon.spy(error => { expect(error).to.be.instanceOf(Error) }) From d173bdf8e20878515422ce4e4ae84586ef59c4b2 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:41:33 -0700 Subject: [PATCH 036/209] Merge pull request #25355 from overleaf/mf-whitelist-staging-url-stripe-test [web] Bypass country requirement for Stripe if user is on staging or dev environment to ease the testing process GitOrigin-RevId: 0924a57d3a1b7b530a3822fb8f9056a1dd7119e9 --- .../src/Features/Subscription/SubscriptionController.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4a69acf56d..4be61d255c 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -704,7 +704,7 @@ async function getRecommendedCurrency(req, res) { ip = req.query.ip } const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip) - let countryCode = currencyLookup.countryCode + const countryCode = currencyLookup.countryCode const recommendedCurrency = currencyLookup.currencyCode let currency = null @@ -715,13 +715,6 @@ async function getRecommendedCurrency(req, res) { currency = recommendedCurrency } - const queryCountryCode = req.query.countryCode?.toUpperCase() - - // only enable countryCode testing flag on staging or dev environments - if (queryCountryCode && process.env.NODE_ENV !== 'production') { - countryCode = queryCountryCode - } - return { currency, recommendedCurrency, From f11ea06c1a399f65e24c0a30e1ebdbbe12a50e0a Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:27 -0400 Subject: [PATCH 037/209] Merge pull request #25910 from overleaf/em-track-changes-sharejs Track changes in the history OT sharejs doc GitOrigin-RevId: 17365219f24a25790eac611dbde9681eb73d0961 --- .../context/editor-manager-context.tsx | 6 +- .../ide-react/editor/document-container.ts | 8 +- .../features/ide-react/editor/share-js-doc.ts | 32 +- .../editor/share-js-history-ot-type.ts | 130 ++++---- .../source-editor/extensions/history-ot.ts | 290 ++++++++++++++++++ .../source-editor/extensions/index.ts | 5 +- .../source-editor/extensions/realtime.ts | 169 +++++++++- .../hooks/use-codemirror-scope.ts | 4 +- .../web/frontend/js/vendor/libs/sharejs.js | 5 +- .../source-editor/source-editor.stories.tsx | 2 +- .../source-editor/helpers/mock-doc.ts | 14 +- services/web/types/share-doc.ts | 16 +- 12 files changed, 578 insertions(+), 103 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/history-ot.ts diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index e1bb49c39c..e830d7ec1a 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -18,6 +18,7 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' import { useLayoutContext } from '@/shared/context/layout-context' +import { useUserContext } from '@/shared/context/user-context' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { Doc } from '../../../../../types/doc' import { useFileTreeData } from '@/shared/context/file-tree-data-context' @@ -99,6 +100,7 @@ export const EditorManagerProvider: FC = ({ const { view, setView } = useLayoutContext() const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } = useModalsContext() + const { id: userId } = useUserContext() const [showSymbolPalette, setShowSymbolPalette] = useScopeValue( 'editor.showSymbolPalette' @@ -309,7 +311,7 @@ export const EditorManagerProvider: FC = ({ const tryToggle = () => { const saved = doc.getInflightOp() == null && doc.getPendingOp() == null if (saved) { - doc.setTrackingChanges(want) + doc.setTrackChangesUserId(want ? userId : null) setTrackChanges(want) } else { syncTimeoutRef.current = window.setTimeout(tryToggle, 100) @@ -318,7 +320,7 @@ export const EditorManagerProvider: FC = ({ tryToggle() }, - [setTrackChanges] + [setTrackChanges, userId] ) const doOpenNewDocument = useCallback( diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index fee359f146..2ded041fb1 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -196,9 +196,13 @@ export class DocumentContainer extends EventEmitter { return this.doc?.hasBufferedOps() } - setTrackingChanges(track_changes: boolean) { + setTrackChangesUserId(userId: string | null) { + this.track_changes_as = userId if (this.doc) { - this.doc.track_changes = track_changes + this.doc.setTrackChangesUserId(userId) + } + if (this.cm6) { + this.cm6.setTrackChangesUserId(userId) } } diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index a773684dcb..e94de4e88b 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -12,18 +12,14 @@ import { Message, ShareJsConnectionState, ShareJsOperation, - ShareJsTextType, TrackChangesIdSeeds, } from '@/features/ide-react/editor/types/document' import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' -import { HistoryOTType } from './share-js-history-ot-type' -import { StringFileData } from 'overleaf-editor-core/index' -import { - RawEditOperation, - StringFileRawData, -} from 'overleaf-editor-core/lib/types' +import { historyOTType } from './share-js-history-ot-type' +import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' +import { StringFileRawData } from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -68,19 +64,17 @@ export class ShareJsDoc extends EventEmitter { readonly type: OTType = 'sharejs-text-ot' ) { super() - let sharejsType: ShareJsTextType = sharejs.types.text + let sharejsType // Decode any binary bits of data let snapshot: string | StringFileData if (this.type === 'history-ot') { snapshot = StringFileData.fromRaw( docLines as unknown as StringFileRawData ) - sharejsType = new HistoryOTType(snapshot) as ShareJsTextType< - StringFileData, - RawEditOperation[] - > + sharejsType = historyOTType } else { snapshot = docLines.map(line => decodeUtf8(line)).join('\n') + sharejsType = sharejs.types.text } this.connection = { @@ -159,6 +153,18 @@ export class ShareJsDoc extends EventEmitter { this.removeCarriageReturnCharFromShareJsDoc() } + setTrackChangesUserId(userId: string | null) { + this.track_changes = userId != null + } + + getTrackedChanges() { + if (this._doc.otType === 'history-ot') { + return this._doc.snapshot.getTrackedChanges() as TrackedChangeList + } else { + return null + } + } + private removeCarriageReturnCharFromShareJsDoc() { const doc = this._doc let nextPos @@ -365,7 +371,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) }) } diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index fde66d89a1..2832ca390e 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,4 +1,3 @@ -import EventEmitter from '@/utils/EventEmitter' import { EditOperationBuilder, EditOperationTransformer, @@ -9,75 +8,28 @@ import { TextOperation, } from 'overleaf-editor-core' import { RawEditOperation } from 'overleaf-editor-core/lib/types' +import { ShareDoc } from '../../../../../types/share-doc' -export class HistoryOTType extends EventEmitter { - // stub interface, these are actually on the Doc - api: HistoryOTType - snapshot: StringFileData +type Api = { + otType: 'history-ot' + trackChangesUserId: string | null - constructor(snapshot: StringFileData) { - super() - this.api = this - this.snapshot = snapshot - } + getText(): string + getLength(): number + _register(): void +} - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] - } - - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) - this.snapshot = afterFile - return afterFile - } - - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] - } - - // Do not provide normalize, used by submitOp to fixup bad input. - // normalize(op: TextOperation) {} - - // Do not provide invert, only needed for reverting a rejected update. - // We are displaying an out-of-sync modal when an op is rejected. - // invert(op: TextOperation) {} - - // API - insert(pos: number, text: string, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.insert(text) - op.retain(old.length - pos) - this.submitOp([op.toJSON()]) - } - - del(pos: number, length: number, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.remove(length) - op.retain(old.length - pos - length) - this.submitOp([op.toJSON()]) - } +const api: Api & ThisType = { + otType: 'history-ot', + trackChangesUserId: null, getText() { - return this.snapshot.getContent({ filterTrackedDeletes: true }) - } + return this.snapshot.getContent() + }, getLength() { - return this.getText().length - } + return this.snapshot.getStringLength() + }, _register() { this.on( @@ -95,10 +47,14 @@ export class HistoryOTType extends EventEmitter { let outputCursor = 0 let inputCursor = 0 + let trackedChangesInvalidated = false for (const op of operation.ops) { if (op instanceof RetainOp) { inputCursor += op.length outputCursor += op.length + if (op.tracking != null) { + trackedChangesInvalidated = true + } } else if (op instanceof InsertOp) { this.emit( 'insert', @@ -107,6 +63,7 @@ export class HistoryOTType extends EventEmitter { op.insertion.length ) outputCursor += op.insertion.length + trackedChangesInvalidated = true } else if (op instanceof RemoveOp) { this.emit( 'delete', @@ -114,20 +71,57 @@ export class HistoryOTType extends EventEmitter { str.slice(inputCursor, inputCursor + op.length) ) inputCursor += op.length + trackedChangesInvalidated = true } } - if (inputCursor !== str.length) + if (inputCursor !== str.length) { throw new TextOperation.ApplyError( "The operation didn't operate on the whole string.", operation, str ) + } + + if (trackedChangesInvalidated) { + this.emit('tracked-changes-invalidated') + } } } ) - } - - // stub-interface, provided by sharejs.Doc - submitOp(op: RawEditOperation[]) {} + }, +} + +export const historyOTType = { + api, + + transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { + const [a, b] = EditOperationTransformer.transform( + EditOperationBuilder.fromJSON(raw1[0]), + EditOperationBuilder.fromJSON(raw2[0]) + ) + return [[a.toJSON()], [b.toJSON()]] + }, + + apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { + const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + const afterFile = StringFileData.fromRaw(snapshot.toRaw()) + afterFile.edit(operation) + return afterFile + }, + + compose(op1: RawEditOperation[], op2: RawEditOperation[]) { + return [ + EditOperationBuilder.fromJSON(op1[0]) + .compose(EditOperationBuilder.fromJSON(op2[0])) + .toJSON(), + ] + }, + + // Do not provide normalize, used by submitOp to fixup bad input. + // normalize(op: TextOperation) {} + + // Do not provide invert, only needed for reverting a rejected update. + // We are displaying an out-of-sync modal when an op is rejected. + // invert(op: TextOperation) {} } diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts new file mode 100644 index 0000000000..58c2a42540 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -0,0 +1,290 @@ +import { Decoration, EditorView } from '@codemirror/view' +import { + ChangeSpec, + EditorState, + StateEffect, + StateField, + Transaction, +} from '@codemirror/state' +import { + CommentList, + EditOperation, + TextOperation, + TrackingProps, + TrackedChangeList, +} from 'overleaf-editor-core' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' + +export const historyOT = (currentDoc: DocumentContainer) => { + const trackedChanges = currentDoc.doc?.getTrackedChanges() + return [ + trackChangesUserIdState, + commentsState, + trackedChanges != null + ? trackedChangesState.init(() => + buildTrackedChangesDecorations(trackedChanges) + ) + : trackedChangesState, + trackedChangesFilter, + rangesTheme, + ] +} + +const rangesTheme = EditorView.theme({ + '.tracked-change-insertion': { + backgroundColor: 'rgba(0, 255, 0, 0.2)', + }, + '.tracked-change-deletion': { + backgroundColor: 'rgba(255, 0, 0, 0.2)', + }, + '.comment': { + backgroundColor: 'rgba(255, 255, 0, 0.2)', + }, +}) + +const updateTrackedChangesEffect = StateEffect.define() + +export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => { + return { + effects: updateTrackedChangesEffect.of(trackedChanges), + } +} + +const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) => + Decoration.set( + trackedChanges.asSorted().map(change => + Decoration.mark({ + class: + change.tracking.type === 'insert' + ? 'tracked-change-insertion' + : 'tracked-change-deletion', + tracking: change.tracking, + }).range(change.range.pos, change.range.end) + ), + true + ) + +const trackedChangesState = StateField.define({ + create() { + return Decoration.none + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateTrackedChangesEffect)) { + value = buildTrackedChangesDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +const setTrackChangesUserIdEffect = StateEffect.define() + +export const setTrackChangesUserId = (userId: string | null) => { + return { + effects: setTrackChangesUserIdEffect.of(userId), + } +} + +const trackChangesUserIdState = StateField.define({ + create() { + return null + }, + + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(setTrackChangesUserIdEffect)) { + value = effect.value + } + } + return value + }, +}) + +const updateCommentsEffect = StateEffect.define() + +export const updateComments = (comments: CommentList) => { + return { + effects: updateCommentsEffect.of(comments), + } +} + +const buildCommentsDecorations = (comments: CommentList) => + Decoration.set( + comments.toArray().flatMap(comment => + comment.ranges.map(range => + Decoration.mark({ + class: 'tracked-change-comment', + id: comment.id, + resolved: comment.resolved, + }).range(range.pos, range.end) + ) + ), + true + ) + +const commentsState = StateField.define({ + create() { + return Decoration.none // TODO: init from snapshot + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateCommentsEffect)) { + value = buildCommentsDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +export const historyOTOperationEffect = StateEffect.define() + +const trackedChangesFilter = EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || tr.annotation(Transaction.remote)) { + return tr + } + + const trackingUserId = tr.startState.field(trackChangesUserIdState) + const startDoc = tr.startState.doc + const changes: ChangeSpec[] = [] + const opBuilder = new OperationBuilder(startDoc.length) + + if (trackingUserId == null) { + // Not tracking changes + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insert + if (inserted.length > 0) { + opBuilder.insert(fromA, inserted.toString()) + } + + // deletion + if (toA > fromA) { + opBuilder.delete(fromA, toA - fromA) + } + }) + } else { + // Tracking changes + const timestamp = new Date() + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insertion + if (inserted.length > 0) { + opBuilder.trackedInsert( + fromA, + inserted.toString(), + trackingUserId, + timestamp + ) + } + + // deletion + if (toA > fromA) { + const deleted = startDoc.sliceString(fromA, toA) + // re-insert the deleted text after the inserted text + changes.push({ + from: fromB + inserted.length, + insert: deleted, + }) + + opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp) + } + }) + } + + const op = opBuilder.finish() + return [ + tr, + { changes, effects: historyOTOperationEffect.of([op]), sequential: true }, + ] +}) + +/** + * Incrementally builds a TextOperation from a series of inserts and deletes. + * + * This relies on inserts and deletes being ordered by document position. This + * is not clear in the documentation, but has been confirmed by Marijn in + * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10 + */ +class OperationBuilder { + /** + * Source document length + */ + private docLength: number + + /** + * Position in the source document + */ + private pos: number + + /** + * Operation built + */ + private op: TextOperation + + constructor(docLength: number) { + this.docLength = docLength + this.op = new TextOperation() + this.pos = 0 + } + + insert(pos: number, text: string) { + this.retainUntil(pos) + this.op.insert(text) + } + + delete(pos: number, length: number) { + this.retainUntil(pos) + this.op.remove(length) + this.pos += length + } + + trackedInsert(pos: number, text: string, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.insert(text, { + tracking: new TrackingProps('insert', userId, timestamp), + }) + } + + trackedDelete(pos: number, length: number, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.retain(length, { + tracking: new TrackingProps('delete', userId, timestamp), + }) + this.pos += length + } + + retainUntil(pos: number) { + if (pos > this.pos) { + this.op.retain(pos - this.pos) + this.pos = pos + } else if (pos < this.pos) { + throw Error( + `Out of order: position ${pos} comes before current position: ${this.pos}` + ) + } + } + + finish() { + this.retainUntil(this.docLength) + return this.op + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 0a65739c55..0e19d42fc1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -50,6 +50,7 @@ import { docName } from './doc-name' import { fileTreeItemDrop } from './file-tree-item-drop' import { mathPreview } from './math-preview' import { ranges } from './ranges' +import { historyOT } from './history-ot' import { trackDetachedComments } from './track-detached-comments' import { reviewTooltip } from './review-tooltip' @@ -142,7 +143,9 @@ export const createExtensions = (options: Record): Extension[] => [ // NOTE: `emptyLineFiller` needs to be before `trackChanges`, // so the decorations are added in the correct order. emptyLineFiller(), - ranges(), + options.currentDoc.currentDocument.getType() === 'history-ot' + ? historyOT(options.currentDoc.currentDocument) + : ranges(), trackDetachedComments(options.currentDoc), visual(options.visual), mathPreview(options.settings.mathPreview), diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 9118e4f151..72ad016f41 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -1,11 +1,26 @@ -import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state' +import { + Prec, + Transaction, + Annotation, + ChangeSpec, + Text, +} from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' import RangesTracker from '@overleaf/ranges-tracker' -import { ShareDoc } from '../../../../../types/share-doc' +import { + ShareDoc, + ShareLatexOTShareDoc, + HistoryOTShareDoc, +} from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { OTType } from '@/features/ide-react/editor/share-js-doc' +import { TrackedChangeList } from 'overleaf-editor-core' +import { + updateTrackedChanges, + setTrackChangesUserId, + historyOTOperationEffect, +} from './history-ot' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -26,8 +41,10 @@ import { OTType } from '@/features/ide-react/editor/share-js-doc' * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js */ +type Origin = 'remote' | 'undo' | 'reject' | undefined + export type ChangeDescription = { - origin: 'remote' | 'undo' | 'reject' | undefined + origin: Origin inserted: boolean removed: boolean } @@ -126,9 +143,13 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) { + this.view.dispatch(updateTrackedChanges(trackedChanges)) + } + + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { this.otAdapter = - type === 'history-ot' + shareDoc.otType === 'history-ot' ? new HistoryOTAdapter(this, shareDoc, maxDocLength) : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) this.otAdapter.attachShareJs() @@ -148,12 +169,18 @@ export class EditorFacade extends EventEmitter { this.otAdapter.handleUpdateFromCM(transactions, ranges) } + + setTrackChangesUserId(userId: string | null) { + if (this.otAdapter instanceof HistoryOTAdapter) { + this.view.dispatch(setTrackChangesUserId(userId)) + } + } } class ShareLatexOTAdapter { constructor( public editor: EditorFacade, - private shareDoc: ShareDoc, + private shareDoc: ShareLatexOTShareDoc, private maxDocLength?: number ) { this.editor = editor @@ -279,7 +306,133 @@ class ShareLatexOTAdapter { } } -class HistoryOTAdapter extends ShareLatexOTAdapter {} +class HistoryOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: HistoryOTShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + + attachShareJs() { + this.checkContent() + + const onInsert = this.onShareJsInsert.bind(this) + const onDelete = this.onShareJsDelete.bind(this) + const onTrackedChangesInvalidated = + this.onShareJsTrackedChangesInvalidated.bind(this) + + this.shareDoc.on('insert', onInsert) + this.shareDoc.on('delete', onDelete) + this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated) + + this.shareDoc.detach_cm6 = () => { + this.shareDoc.removeListener('insert', onInsert) + this.shareDoc.removeListener('delete', onDelete) + this.shareDoc.removeListener( + 'tracked-changes-invalidated', + onTrackedChangesInvalidated + ) + delete this.shareDoc.detach_cm6 + this.editor.detachShareJs() + } + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + for (const transaction of transactions) { + if ( + this.maxDocLength && + transaction.changes.newLength >= this.maxDocLength + ) { + this.shareDoc.emit( + 'error', + new Error('document length is greater than maxDocLength') + ) + return + } + + let snapshotUpdated = false + for (const effect of transaction.effects) { + if (effect.is(historyOTOperationEffect)) { + this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + snapshotUpdated = true + } + } + + if (snapshotUpdated || transaction.annotation(Transaction.remote)) { + window.setTimeout(() => { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + }, 0) + } + + const origin = chooseOrigin(transaction) + transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin) + }) + } + } + + onShareJsInsert(pos: number, text: string) { + this.editor.cmInsert(pos, text, 'remote') + this.checkContent() + } + + onShareJsDelete(pos: number, text: string) { + this.editor.cmDelete(pos, text, 'remote') + this.checkContent() + } + + onShareJsTrackedChangesInvalidated() { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + } + + onCodeMirrorChange( + fromA: number, + toA: number, + fromB: number, + toB: number, + insertedText: Text, + origin: Origin + ) { + const insertedLength = insertedText.length + const removedLength = toA - fromA + const inserted = insertedLength > 0 + const removed = removedLength > 0 + + const changeDescription: ChangeDescription = { + origin, + inserted, + removed, + } + + this.editor.emit('change', this.editor, changeDescription) + } + + checkContent() { + // run in a timeout so it checks the editor content once this update has been applied + window.setTimeout(() => { + const editorText = this.editor.getValue() + const otText = this.shareDoc.getText() + + if (editorText !== otText) { + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') + debugConsole.error('Text does not match!') + debugConsole.error('editor: ' + editorText) + debugConsole.error('ot: ' + otText) + } + }, 0) + } +} export const trackChangesAnnotation = Annotation.define() 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 a4e2862e1f..2504afdd0c 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 @@ -185,9 +185,9 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { if (trackChanges) { - currentDocument.track_changes_as = userId || 'anonymous' + currentDocument.setTrackChangesUserId(userId ?? 'anonymous') } else { - currentDocument.track_changes_as = null + currentDocument.setTrackChangesUserId(null) } } }, [userId, currentDocument, trackChanges]) diff --git a/services/web/frontend/js/vendor/libs/sharejs.js b/services/web/frontend/js/vendor/libs/sharejs.js index accc2b5b04..52e201ce37 100644 --- a/services/web/frontend/js/vendor/libs/sharejs.js +++ b/services/web/frontend/js/vendor/libs/sharejs.js @@ -680,6 +680,7 @@ export const { Doc } = (() => { // Text document API for text text.api = { + otType: "sharejs-text-ot", provides: { text: true }, // The number of characters in the string @@ -1008,8 +1009,8 @@ export const { Doc } = (() => { this.type = type; if (type.api) { - for (const k of ['insert', 'del', 'getText', 'getLength', '_register']) { - this[k] = type.api[k] + for (var k in type.api) { + var v = type.api[k];this[k] = v; } return typeof this._register === 'function' ? this._register() : undefined; } else { diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index d87179b65f..3cc6b1c95f 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -198,7 +198,7 @@ const mockDoc = (content: string, changes: Array> = []) => { setTrackChangesIdSeeds: () => { // Do nothing }, - setTrackingChanges: () => { + setTrackChangesUserId: () => { // Do nothing }, getTrackingChanges: () => { diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index 4c239c1f60..f13d9ad6bb 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -1,4 +1,4 @@ -import { ShareDoc } from '../../../../../types/share-doc' +import { ShareLatexOTShareDoc } from '../../../../../types/share-doc' import { EventEmitter } from 'events' export const docId = 'test-doc' @@ -36,6 +36,9 @@ const defaultContent = mockDocContent(contentLines.join('\n')) const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength class MockShareDoc extends EventEmitter { + otType = 'sharejs-text-ot' as const + snapshot = '' + constructor(public text: string) { super() } @@ -51,16 +54,21 @@ class MockShareDoc extends EventEmitter { del() { // do nothing } + + submitOp() { + // do nothing + } } export const mockDoc = ( content = defaultContent, { rangesOptions = {} } = {} ) => { - const mockShareJSDoc: ShareDoc = new MockShareDoc(content) + const mockShareJSDoc: ShareLatexOTShareDoc = new MockShareDoc(content) return { doc_id: docId, + getType: () => 'sharejs-text-ot', getSnapshot: () => { return content }, @@ -101,7 +109,7 @@ export const mockDoc = ( submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, - setTrackingChanges: () => {}, + setTrackChangesUserId: () => {}, getInflightOp: () => null, getPendingOp: () => null, hasBufferedOps: () => false, diff --git a/services/web/types/share-doc.ts b/services/web/types/share-doc.ts index d071c97f28..7c75e6d0de 100644 --- a/services/web/types/share-doc.ts +++ b/services/web/types/share-doc.ts @@ -1,9 +1,23 @@ import EventEmitter from 'events' +import { StringFileData } from 'overleaf-editor-core' // type for the Doc class in vendor/libs/sharejs.js -export interface ShareDoc extends EventEmitter { +export interface ShareLatexOTShareDoc extends EventEmitter { + otType: 'sharejs-text-ot' + snapshot: string detach_cm6?: () => void getText: () => string insert: (pos: number, insert: string, fromUndo: boolean) => void del: (pos: number, length: number, fromUndo: boolean) => void + submitOp(op: any[]): void } + +export interface HistoryOTShareDoc extends EventEmitter { + otType: 'history-ot' + snapshot: StringFileData + detach_cm6?: () => void + getText: () => string + submitOp(op: any[]): void +} + +export type ShareDoc = ShareLatexOTShareDoc | HistoryOTShareDoc From 7a556cf1fdb84ba69f6ee690b62397c47a8a4ac4 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:42 -0400 Subject: [PATCH 038/209] Merge pull request #26041 from overleaf/em-history-ot-type-serialize History OT type: operate on parsed EditOperations GitOrigin-RevId: dbb35789736958d4ef398e566400d6e9a0e49e8b --- .../features/ide-react/editor/share-js-doc.ts | 21 +++- .../editor/share-js-history-ot-type.ts | 117 ++++++++---------- .../ide-react/editor/types/document.ts | 2 + .../source-editor/extensions/realtime.ts | 2 +- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index e94de4e88b..5b362299d2 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -18,8 +18,15 @@ import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' import { historyOTType } from './share-js-history-ot-type' -import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' -import { StringFileRawData } from 'overleaf-editor-core/lib/types' +import { + StringFileData, + TrackedChangeList, + EditOperationBuilder, +} from 'overleaf-editor-core' +import { + StringFileRawData, + RawEditOperation, +} from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -259,7 +266,15 @@ export class ShareJsDoc extends EventEmitter { // issues are resolved. processUpdateFromServer(message: Message) { try { - this._doc._onMessage(message) + if (this.type === 'history-ot' && message.op != null) { + const ops = message.op as RawEditOperation[] + this._doc._onMessage({ + ...message, + op: ops.map(EditOperationBuilder.fromJSON), + }) + } else { + this._doc._onMessage(message) + } } catch (error) { // Version mismatches are thrown as errors debugConsole.log(error) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 2832ca390e..4621fd07fb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,5 +1,5 @@ import { - EditOperationBuilder, + EditOperation, EditOperationTransformer, InsertOp, RemoveOp, @@ -7,7 +7,6 @@ import { StringFileData, TextOperation, } from 'overleaf-editor-core' -import { RawEditOperation } from 'overleaf-editor-core/lib/types' import { ShareDoc } from '../../../../../types/share-doc' type Api = { @@ -32,90 +31,74 @@ const api: Api & ThisType = { }, _register() { - this.on( - 'remoteop', - (rawEditOperation: RawEditOperation[], oldSnapshot: StringFileData) => { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - if (operation instanceof TextOperation) { - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) + this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => { + const operation = ops[0] + if (operation instanceof TextOperation) { + const str = oldSnapshot.getContent() + if (str.length !== operation.baseLength) + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str + ) - let outputCursor = 0 - let inputCursor = 0 - let trackedChangesInvalidated = false - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - if (op.tracking != null) { - trackedChangesInvalidated = true - } - } else if (op instanceof InsertOp) { - this.emit( - 'insert', - outputCursor, - op.insertion, - op.insertion.length - ) - outputCursor += op.insertion.length - trackedChangesInvalidated = true - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) - ) - inputCursor += op.length + let outputCursor = 0 + let inputCursor = 0 + let trackedChangesInvalidated = false + for (const op of operation.ops) { + if (op instanceof RetainOp) { + inputCursor += op.length + outputCursor += op.length + if (op.tracking != null) { trackedChangesInvalidated = true } - } - - if (inputCursor !== str.length) { - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str + } else if (op instanceof InsertOp) { + this.emit('insert', outputCursor, op.insertion, op.insertion.length) + outputCursor += op.insertion.length + trackedChangesInvalidated = true + } else if (op instanceof RemoveOp) { + this.emit( + 'delete', + outputCursor, + str.slice(inputCursor, inputCursor + op.length) ) - } - - if (trackedChangesInvalidated) { - this.emit('tracked-changes-invalidated') + inputCursor += op.length + trackedChangesInvalidated = true } } + + if (inputCursor !== str.length) { + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } + + if (trackedChangesInvalidated) { + this.emit('tracked-changes-invalidated') + } } - ) + }) }, } export const historyOTType = { api, - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] + transformX(ops1: EditOperation[], ops2: EditOperation[]) { + const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0]) + return [[a], [b]] }, - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + apply(snapshot: StringFileData, ops: EditOperation[]) { const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) + afterFile.edit(ops[0]) return afterFile }, - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] + compose(ops1: EditOperation[], ops2: EditOperation[]) { + return [ops1[0].compose(ops2[0])] }, // Do not provide normalize, used by submitOp to fixup bad input. diff --git a/services/web/frontend/js/features/ide-react/editor/types/document.ts b/services/web/frontend/js/features/ide-react/editor/types/document.ts index fbed3ab8f1..f6e5f6aebb 100644 --- a/services/web/frontend/js/features/ide-react/editor/types/document.ts +++ b/services/web/frontend/js/features/ide-react/editor/types/document.ts @@ -1,5 +1,6 @@ import { StringFileData } from 'overleaf-editor-core' import { AnyOperation } from '../../../../../../types/change' +import { RawEditOperation } from 'overleaf-editor-core/lib/types' export type Version = number @@ -36,4 +37,5 @@ export type Message = { doc?: string snapshot?: string | StringFileData type?: ShareJsTextType + op?: AnyOperation[] | RawEditOperation[] } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 72ad016f41..1797cbc17e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -360,7 +360,7 @@ class HistoryOTAdapter { let snapshotUpdated = false for (const effect of transaction.effects) { if (effect.is(historyOTOperationEffect)) { - this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + this.shareDoc.submitOp(effect.value) snapshotUpdated = true } } From a134a2b799740d553e5c325ef9550884b0ed831d Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:02:14 +0200 Subject: [PATCH 039/209] [web] support purchasing/removing add-ons for Stripe subscriptions (#26081) GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da --- .../src/Features/Subscription/PlansLocator.js | 31 +++++++++- .../Subscription/SubscriptionController.js | 2 - .../Subscription/SubscriptionHandler.js | 62 ++----------------- .../src/Subscription/PlansLocatorTests.js | 41 ++++++++++++ services/web/types/subscription/plan.ts | 6 ++ 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index 24343e1109..c04f0c860d 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -4,7 +4,9 @@ const logger = require('@overleaf/logger') /** * @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode + * @typedef {import('../../../../types/subscription/plan').RecurlyAddOnCode} RecurlyAddOnCode * @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey + * @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval */ function ensurePlansAreSetupCorrectly() { @@ -38,7 +40,7 @@ const recurlyPlanCodeToStripeLookupKey = { group_professional_educational: 'group_professional_educational', group_collaborator: 'group_standard_enterprise', group_collaborator_educational: 'group_standard_educational', - assistant_annual: 'error_assist_annual', + 'assistant-annual': 'error_assist_annual', assistant: 'error_assist_monthly', } @@ -51,6 +53,28 @@ function mapRecurlyPlanCodeToStripeLookupKey(recurlyPlanCode) { return recurlyPlanCodeToStripeLookupKey[recurlyPlanCode] } +/** + * + * @param {RecurlyAddOnCode} recurlyAddOnCode + * @param {BillingCycleInterval} billingCycleInterval + * @returns {StripeLookupKey|null} + */ +function mapRecurlyAddOnCodeToStripeLookupKey( + recurlyAddOnCode, + billingCycleInterval +) { + // Recurly always uses 'assistant' as the code regardless of the subscription duration + if (recurlyAddOnCode === 'assistant') { + if (billingCycleInterval === 'month') { + return 'error_assist_monthly' + } + if (billingCycleInterval === 'year') { + return 'error_assist_annual' + } + } + return null +} + const recurlyPlanCodeToPlanTypeAndPeriod = { collaborator: { planType: 'individual', period: 'monthly' }, collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, @@ -68,12 +92,14 @@ const recurlyPlanCodeToPlanTypeAndPeriod = { group_professional_educational: { planType: 'group', period: 'annual' }, group_collaborator: { planType: 'group', period: 'annual' }, group_collaborator_educational: { planType: 'group', period: 'annual' }, + assistant: { planType: null, period: 'monthly' }, + 'assistant-annual': { planType: null, period: 'annual' }, } /** * * @param {RecurlyPlanCode} recurlyPlanCode - * @returns {{ planType: 'individual' | 'group' | 'student', period: 'annual' | 'monthly'}} + * @returns {{ planType: 'individual' | 'group' | 'student' | null, period: 'annual' | 'monthly'}} */ function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) { return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode] @@ -92,5 +118,6 @@ module.exports = { ensurePlansAreSetupCorrectly, findLocalPlanInSettings, mapRecurlyPlanCodeToStripeLookupKey, + mapRecurlyAddOnCodeToStripeLookupKey, getPlanTypeAndPeriodFromRecurlyPlanCode, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4be61d255c..aa0b97d497 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -414,8 +414,6 @@ async function purchaseAddon(req, res, next) { logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons') try { - // set a restore point in the case of a failed payment for the upgrade (Recurly only) - await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id) await SubscriptionHandler.promises.purchaseAddon( user._id, addOnCode, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 1296a2a7de..8aa0ee84eb 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -1,6 +1,5 @@ // @ts-check -const recurly = require('recurly') const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') @@ -11,11 +10,11 @@ const LimitationsManager = require('./LimitationsManager') const EmailHandler = require('../Email/EmailHandler') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') -const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors') +const { IndeterminateInvoiceError } = require('../Errors/Errors') const Modules = require('../../infrastructure/Modules') /** - * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities' + * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities' */ async function validateNoSubscriptionInRecurly(userId) { @@ -278,24 +277,12 @@ async function previewAddonPurchase(userId, addOnCode) { * @param {number} quantity */ async function purchaseAddon(userId, addOnCode, quantity) { - const subscription = await getSubscriptionForUser(userId) - try { - await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode) - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Add-on not found', - info: { addOnCode }, - }) - } - throw err - } - const changeRequest = subscription.getRequestForAddOnPurchase( + await Modules.promises.hooks.fire( + 'purchaseAddOn', + userId, addOnCode, quantity ) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) } /** @@ -305,44 +292,7 @@ async function purchaseAddon(userId, addOnCode, quantity) { * @param {string} addOnCode */ async function removeAddon(userId, addOnCode) { - const subscription = await getSubscriptionForUser(userId) - const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) -} - -/** - * Returns the Recurly UUID for the given user - * - * Throws a NotFoundError if the subscription can't be found - * - * @param {string} userId - * @return {Promise} - */ -async function getSubscriptionForUser(userId) { - const subscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - const recurlyId = subscription?.recurlySubscription_id - if (recurlyId == null) { - throw new NotFoundError({ - message: 'Recurly subscription not found', - info: { userId }, - }) - } - - try { - const subscription = await RecurlyClient.promises.getSubscription(recurlyId) - return subscription - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Subscription not found', - info: { userId, recurlyId }, - }) - } else { - throw err - } - } + await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode) } async function pauseSubscription(user, pauseCycles) { diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index f705baa01c..0c7a6dca03 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -29,6 +29,7 @@ const plans = [ describe('PlansLocator', function () { beforeEach(function () { this.settings = { plans } + this.AI_ADD_ON_CODE = 'assistant' this.PlansLocator = SandboxedModule.require(modulePath, { requires: { @@ -114,6 +115,46 @@ describe('PlansLocator', function () { }) }) + describe('mapRecurlyAddOnCodeToStripeLookupKey', function () { + it('should return null for unknown add-on codes', function () { + const billingCycleInterval = 'month' + const addOnCode = 'unknown_addon' + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal(null) + }) + + it('should handle missing input', function () { + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + undefined, + undefined + ) + expect(lookupKey).to.equal(null) + }) + + it('returns the key for a monthly AI assist add-on', function () { + const billingCycleInterval = 'month' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_monthly') + }) + + it('returns the key for an annual AI assist add-on', function () { + const billingCycleInterval = 'year' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_annual') + }) + }) + describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () { it('should return the plan type and period for "collaborator"', function () { const { planType, period } = diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index c5e8f7e820..4759bb1255 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -85,6 +85,10 @@ export type RecurlyPlanCode = | 'group_professional_educational' | 'group_collaborator' | 'group_collaborator_educational' + | 'assistant' + | 'assistant-annual' + +export type RecurlyAddOnCode = 'assistant' export type StripeLookupKey = | 'standard_monthly' @@ -97,3 +101,5 @@ export type StripeLookupKey = | 'group_professional_enterprise' | 'group_standard_educational' | 'group_professional_educational' + | 'error_assist_annual' + | 'error_assist_monthly' From db98f5132b262a10f9a659859686524780b28233 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:46:58 +0100 Subject: [PATCH 040/209] Merge pull request #25939 from overleaf/dp-error-logs Update error logs designs for new editor GitOrigin-RevId: 0de3a54446a0ff114a1debb7b5f274d3a8f19c42 --- .../src/Features/Project/ProjectController.js | 1 + services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 4 +- ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 4444 -> 4612 bytes .../material-symbols/unfilled-symbols.mjs | 1 + .../ide-redesign/components/chat/chat.tsx | 2 +- .../error-indicator.tsx} | 14 +- .../error-logs/error-logs-header.tsx | 98 ++++++ .../error-logs/error-logs-panel.tsx | 14 + .../components/error-logs/error-logs.tsx | 133 ++++++++ .../error-logs/log-entry-header.tsx | 153 +++++++++ .../components/error-logs/log-entry.tsx | 109 ++++++ .../components/error-logs/old-error-pane.tsx | 10 + .../integrations-panel/integrations-panel.tsx | 2 +- .../components/rail-panel-header.tsx | 31 ++ .../features/ide-redesign/components/rail.tsx | 41 ++- .../components/pdf-log-entry-content.tsx | 8 +- .../components/pdf-log-entry-raw-content.tsx | 12 +- .../pdf-preview/components/pdf-log-entry.tsx | 39 ++- .../components/pdf-preview-error.tsx | 312 ++++++++++-------- .../timeout-upgrade-paywall-prompt.tsx | 10 +- .../components/preview-log-entry-header.tsx | 14 - .../shared/context/local-compile-context.tsx | 9 +- .../stories/pdf-log-entry.stories.tsx | 1 - .../bootstrap-5/pages/editor/logs.scss | 244 +++++++++++--- .../bootstrap-5/pages/editor/rail.scss | 16 +- services/web/locales/da.json | 1 - services/web/locales/de.json | 1 - services/web/locales/en.json | 4 +- services/web/locales/fr.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/zh-CN.json | 1 - 32 files changed, 1021 insertions(+), 267 deletions(-) rename services/web/frontend/js/features/ide-redesign/components/{errors.tsx => error-logs/error-indicator.tsx} (56%) create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 842215d80e..e88cb53449 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -352,6 +352,7 @@ const _ProjectController = { 'overleaf-assist-bundle', 'word-count-client', 'editor-popup-ux-survey', + 'new-editor-error-logs-redesign', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index d8892e70ff..0d3ea86314 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -966,6 +966,7 @@ module.exports = { editorToolbarButtons: [], sourceEditorExtensions: [], sourceEditorComponents: [], + pdfLogEntryHeaderActionComponents: [], pdfLogEntryComponents: [], pdfLogEntriesComponents: [], pdfPreviewPromotions: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index e5bb2fced3..cad43ed4e1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -411,7 +411,6 @@ "discount": "", "discount_of": "", "discover_the_fastest_way_to_search_and_cite": "", - "dismiss_error_popup": "", "display": "", "display_deleted_user": "", "display_math": "", @@ -680,6 +679,7 @@ "go_page": "", "go_prev_page": "", "go_to_account_settings": "", + "go_to_code_location": "", "go_to_code_location_in_pdf": "", "go_to_overleaf": "", "go_to_pdf_location_in_code": "", @@ -969,6 +969,7 @@ "login_count": "", "login_to_accept_invitation": "", "login_with_service": "", + "logs": "", "logs_and_output_files": "", "looking_multiple_licenses": "", "looks_like_youre_at": "", @@ -1039,6 +1040,7 @@ "more_compile_time": "", "more_editor_toolbar_item": "", "more_info": "", + "more_logs_and_files": "", "more_options": "", "my_library": "", "n_items": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 8e72799b077bd442cf7211bd1488813826bc9018..bc2ec87d3ab482401e7f59c91abac055c319e539 100644 GIT binary patch literal 4612 zcmV+f68r6UPew8T0RR9101^ZM4gdfE03^Tw01>JH0RR9100000000000000000000 z0000Shd2gcKT}jeRAK;vR1pXYvv{k23uXWTHUcCAW&|Jwg$@TG3SpD(q&p-eD;CWh0-=D=}%-$Z%EF`Q_0Cd14baYiU$|@AHPS*ARe^>L~BxCQz zU?Z5>r&uQ0bzq<(n%8>LdIXgbMC`NmmD4})p>A`iGiB#f&^gv4vHeUq7Z|5d0buOb z|NZXmcJ+wP_H82nScEL=)z;p@xT0Vf=`bUU`n1z*u_N?5j#9$c7#466HxQzL; zlYc*o|9|e)Cci_}Tyzy~=r$wfcYJg6k}kP*li9K(EkHuwW;lR z0yPVy=hUQ}5UVaiG)~(NiNu z94*ZZpA3d!(8yR(h)MLJ5kuw)8L~izdV%sK0;Zg3ngAGypePRX4wt1!NS}mMK-%nH z!Z2WtR~7KlDqwx!0XFLd)XCxojc3fEso{VWM0SllGqVxphG8V_*5qh2!!ZIP&9x9s zaLEkrn1qN_6C$!>1Yazf2(ng8S+L-_e!!FWa$fTM;V4_FqbTE2$%g6^3_kh$q)TX0 z5)N3zYDN{u6bDL~VzZx=CeQV7j0ACG5z`JkWXe$YT#rI#P|OuQ>SCi$XmjEhAh1(r z3`{TDG(sQ~j3VWUJtU^DEZLZ0MWI+As6xPDt9An$jg2+^sBSe-48YxGLYa!CMc)Bw zT<~aCgJk0fMHQEp88G!mt&YpxnlVc(o6b<=G(hdM9?e246kX5A^7**4*q`xd&ZeTJpk*E^#_B&2|fZI5xR~AtfBTEID9zI zIqo~TW48$M;Ng?kqEIhx+xcIn!$*Yz7qZ{B-?L zCm;by;81wN9;QNwgAbx$d+`YoSPQI#oIS~2d{RyvD7p^<&+~jQBFjV?rO0xXDXJuu z5>!=HWlzie(RIB?Cn1^0w3B5+ z)?`&x+w~(vHH=O|GN~v;nLkJzloHfTW&j}~=_iV5A!*36tYm0L7?C!JkxmPPX-Y{h zudBT~QC%(7Vx5@t&LB^`BC-;t$V!y@1pw+7z|9lFxu-}%>@~lX_JaBG- z`X%I9k$8FF+(IpKj3Kh1;$8&TW$?13;FT@osn9DTZiR&UbzUC8El}JrNa-tKkDYz<5 z$l;FOYh_A0b?2xbBO--tk~DlKJ1HqFf+W0oipxu#?$GQkmHgAdxu?i-$x3ZRw5%$Z z9IjGe3>C*qzXvmT`!RgzJEzCYh&;^-Ts_;$OJs&KC2;l7H=URncI0V+tFQ2o7If{W z)c?36b(FSB8(F!xh8(lpus*pBnwX=;6T-k4tuC8NpADLVjcouazD)(hD=zPI>4;~s z{%k@VI3k{C7gF+HK&`N1U>N$bX;eFEo~l`Hu^fFtGJB)~#j@LTe!5;^TG~`Z9AiKMn zESGFzDYZP|=rmBv@mh!NJUf-=*#4Iz%!jVi!qBnrjD3A-=xPs5RNTBl&MO-BjuZV< zIqhhzxg=UM@#u@AHIq*Q*gH@5cj6yHPCdK*cwZ-W%ZgRn!f^G7q%R6rk4g;;d)u)- zR*)Z^e%qsMgR_I}wHuLb=;REtiml^pVjXH8V0*AkS>HzeJ6g$BX$PflX``o{7Nt|v zT(||yyszP_paNHxr=^h;5Q-Y9yP~@n6BmlmG8t+;i-IY;A_ectWXfa-KNw8O<#Gd% zNF)-JM0q}RgHQ>vh#V82i=b=J+8L23P7K9RBKkamCIV2v0+t3!BxEQh#3+c6s9qD) zBT|}HLS+S12jn<`OJ1>CGQp1NIMW#$(IA8tFcyLWZpB7)!K93`C?AsA>5~#vVV02m zFeOCPp*`Kuf{x(j*c| zK_!%=Kw24;r{qG85t!u7ZYCS&8e_w3D1$O6#>J!*mqH1Yq)qL z+5YC(%sc%JeM|lwLWoBQeT3L%;JUY%MLXzRV}TF`nck$sbcutmYNYcQt2VxLx=PZNhQ(@ekSlRIEk_Ddy*$&d;8g=W|)v`C*If12?j7Ao*O;j`|ysoBAZ; zlK?(jx-ZUwkEb^P!0)XA2_**_#vDjzJ+}Peyx4!-luvoN&lJb|OqQ2V`EdMXy#FN6 z`AjjBn??+tJv+EuXu#vQ6&_bSEP|b@WPWm_$iS@=Pqp-dPS&W3ngVj2W85Kw=o1pm5+|m zAyB4#KpqLgqYmNWEK@YGYDb%j(;wEaDm|r7rS(qqf@K`X)y>f#)|ZL=my~g3b4
~K%gi_OiI+v~ zcNXxztfnmRy+a|2h88v|m36Pj zE3!ZoO+^%$DJg4d`KS&=QNPbh-)5&WJN5?`R;sY55mu@U0S=t$1$4+X4;RMKIf=V2 z=Lx^@yR4di7SsRZJBmA)@vOKQ?-7glU{IONRLi${l!9vg#}P$4$^fcPAxXyAsbf=m z@QklN$RAQtmXHw0tu$UphjFxePH8Cs98%)BSWpI;X4?we7R-t5^{IevNx1kdK6|#N zTq2p8b0x=ABC)SIcMd0s!&F%M39i7Y_ioFU;@YK4$F^rG(FadNQ76EDBVA3YW&Nw!oKa?II*oBi zOGMGwj0}9nyvMR6a;>;uhXj7Cq-qH6qq739d_tLItgLD5ggT-n#S4Ryh?bDjvE5)% z>NwlM$$FPj8emMYd2aS?<>=sPm&MY@(slqyYH7$ML{Y%4ej5OIYM0|!lQ&QAPNU-M zQJqoxCxpIM0@tFm&%3(^SDm9rags=sgn`5tSJy4{?KlM7uXA%q+jZgd2fHYNmy5Oi zaN+YQhcx%WIe0-^(#!Me`yz3f*t4QP!ngHqDUMw)4_bIeMS6NfMtMZ!VEKXH2L?OXUFTg&%N~g+x8<^gbo~5VtzWvf_TKZ( z_GV`G&gbu~jlbKz{hV}^Ty7ENN_s2{a(-`PGQF+p%94FXmF})oNjNtfRu>)aaPv#KI%;WMN)xSY%#!;Ys`vE3F)q&sl?Erw)F;a#?u! z=gSBFSpnKxwDZD+TSl}2U|47Bk@wW3`qyW&!LXs!>)(uIkA#9FDm&|^Ev06amFd&H z75hQO5fheulFFiolvlIDt6h~Qo>NaOE$+XgT~N$f96udE&Y0%PU-SXPDtx8B)oe8( zB|wma_Jbrz4%!crU|7Z3>@!QR|8^&HUwdY-ejl29=#Hl;)U$m*TPXPC&lY`b`Co~A z&6j@`ef6i3FZl}$TPsWEC-BNxWoQE5U5}4NS9BNdw7gSy;mT#*rQ0oU*Il}lJAI#a z&2sI&W7D(uYS%5-?;8cf8ps=M3MJwB1QC{BBx$sfv!X}I zD>QwVJ$+X~Nn%P$XpgnKi7q{|ny{U=#D_+e{RLB0My7MM{z<=VV znicL2e`kwZzt7$0&o6MRpSY=d&-gz6bGyb6-Z^MO$!5=WnkZxT=@`M;&P7rtxN8t0 z3aWv^`=YcpyS@-IUBWNpyWd;w*l7|G-YufN{tYdDF=Hrs+9NqY72;8Htw3e-OG?0r zpxlfV$F9iCviQ{EN^QiJEdT%jzyZL0%f!pCnpB#{T{q(L9tMEV%es#M;PbK>FTGBG zk@p<$4FCdg0RSA}{{fA759@XM>%8aw6A(bcad;2oX*`VQu^vXyxX$rmNT%N#KMW0~ zKx05wV~|8Cb7culgZc>5-;Wm!2~+?aAVes#wCDjjC&^Q%;5*$6=l<55$~Pt!%~^g% zauCZYisVAXNsHt`fo~)EAaN$r08W@4EI=AkWX?~=G!`N`Fo86Z3kNtv@*sq8217Lu zjAIHRfIMi>4F)QxQI1Bmq8TmdMLs&wi&oSNdx}wf>|rz_6Wvgu0gcE&YYQGalnu!{m^E0t9 z^L(4QATz$G8i1$&7tDQh1~3zoiJj7cO*_KbH`)Jx?$zf14v>nj!j0sNnQ_g1FKJ)y zPa(xni84%nfs;QVcAHzL2~d&S@dRoXNKe=qGbyLUjx zXU-bbz5~b3QGRgs#UsiXCgP}Id-mY&ZTu@)AIRVVO8$~hTsZiMe8QI<5{t_>ZgGSa zv@VW0GhZwdH&?Ju|>Mj^#spAkf ziLE+C7IvfB^@u>(Y01-Nfum1|4Oc@f!IgG!CZxnjzx9 zAbEaqjBV6WRB`d3;fNU#9>wd_E3ssx0+6W7^cs#O&YUsFaEN8J$>ZYK$k`Zho1 zA#)0b=eU$ILt?J((v<51LWk3!0Ev~cU}$;KpFqloU(wgYCNs5Wqg(=ufxs|?or z3<|~`6JYcoS(=_r3@0yI6!gd0t2r*|;K`E*`9tvUy9xe(1YhD{$q3D`&4RzqaJsJ=Z?JzUTU->%ZPG zZ!W*}(5<&Ck5!(kyi@tGvaYhXa-?#s^5312cmDms`EluQ)BZeCts;baqY0=5tw0Z< zC$FF@4_rBWb@{a=C}aKPGgAGt`cL)i>apt4>Jfx`ZSS?TXQ9XW9vgbh>wbUth2490 z`?c%7E~~qIWN$~v_U-5F$L*E&e7nWQ)^}EoendNhzt1eTbNptUr=$7rn_*9*Gy+8I z+rL^x8jb#7p<%P{J)&=eD&6GNX)F$R^HL$pWG1$$1pURvRZKffN zoL^WjIRNFyk-w{d;arzb9%QfGYnKle+L?pH)n>HQPRhnT#M)qo<;80N*jk$=I|Aj4IV8qF5tapL~(o0Tvt4h$#sO}CYo$SgWN z^v#IdG+~w(2S(_dcLzu#vi2*&zvx0drKQ$F4LsC9o|%5s___rckSEp?xHlo!RxK-^ z4c-Nt+W<UqBpQJ|v2GVjIPa-%l|dzgUIb zHe04&U`vfyb2U*Fq{NrzRML35nCx{&!HJ~0MD*1zNdAMI-+hE9Hci$7TVu_Pci>=~ z;e=sz3B^3j1PADlAr2h^Gr?6#mpD%0#4zD)2yuK8=!d%Z|B`=fs3!+!02F?9-rQ*< zF!toE@LaPtD0%NTvJ-~1g1N)M6e`r_2H{me6~5MA(K(z~xH=zv3n_MSQvT={Nbq;{ zUmp5pDp5G?7SL;2(wFAciv7b72}xqt_mRYgVKzUGfmv;z+>CrkB;WQxv@P~u$ zuwrJ@pnVhtJmLqGK6he_+}*8srQ%^ns~53A#(^p{Xdk)r>{y=d@$Uwh_g!a15%um% zxH%*8jmJkSep)LZlq_$@iGjN8UcA;`7Oxq3w59Qy(I*b9ohJv;JsCBS6*j=>_ z`RltlLuj#W0v$Xh8m9qFg^Oq6emw9)<6Y~oi>PbOqhGMrAMkKcrovB2K6e-X&6skaS zVmc8bHJKd}hZ0U3r00Tyty{sREe1Hi}ZG~o7nOE-#Hk(g-P z1Rubr(gNLp#WFDc)X`eD$8Hw`MUfWmHan}0Vk=ChifAwJIqyM>v&i;7WEy;slFJA_Lp!b6) zhB|0EcQB32T9e&*k-zIKc^sE#(dqNd9B0YX>CMR@X1y-Y!kM>b7oRw>c)rAj@$S(Yi$t$rqRLtfD}^JjNEQAd=77msab`E!G#44At1W zZJQ{q&pDsP&}70ztmfHs8ns$2Kf~vok5Lx9z^FCO@Y81Uj9%Od%7X8`*u7WYXMNEP zLA(tF>obaepuS6SP2PbrG8H69SRHh&H2%x zq0#f-d^9Q6!xx2pnkvdfGE4tA>SZFFjVa$hZwj3)N%D`!>kh&Ko`?k;Knmi94IkBp z1$-cFX=wW0yq$xgBRf5s|G{?Ruq#3I{G>0Uxdcs4{_lrE?RxlrFSL*E~d@B?Ym%I$M}} z+L{jISH)I?0RYZwah*&sLZ-24Nq7t9C;a?Wz3(amfU}++2m=6@TaFXTe&e!F65Jk@>?-kjP2`WYz`5|o z`+N5Eu4)%9FtsXGEdw;UEPjvFfA|pKMvJx+T~2TlmeKP#?Yf!!tq2CB?}}#U)8at!;&+VG!mmdV7oBzMJ2l^wZCW z1_uiZUC}O2SCmV!0XElp%HTOAiLB!eBvt!=^3}Yk@_D^y_l)$@X#7U*Ioq3jyM6UZ zwSUq9j;u?X;;GO1t<7P>z4}^9BIX)`=$caffRE3BzNVBm1kEjpX!Yt#*={(K-_(@9 z-$;4<4`K43*5UQ}2iKR>sA^U2H(K*nHatY>xAfI;rKex;3q(t3OptOlHA@OVj7tde z@J#TFJLHz*qP?8mqFv&a@yF4`6|08Sh;Dg$L{~$~id7=peyYMLG37|G{Mj(ibR$7# z?%!mb6XIPMJoubVd~@jcmTAzf9ebJhv2?I)`3)8Ze< z-Vb-lxRR}J)cM%pz}#ES{qo-9i>sDgw?UNC^QI6bxtW7mhLpiNuwc2mb9EfY)t#&3 zAWHJ}h_%62f4yD2r!>q)GY9oug6c^gxl;9y$K^ElE1roo3t_kP*B{lx9RZ0+s-vUBT+TYuTviR!widGy5SJuO{5cQ=on z7`vwlqNL@Ne>%?IC5C04W8&@ePdiBlNm6i|Q$j+AW1s|4I*~c1C@dKkgged&clajy z1qLR0``nQ&d}N$`{96}%di2&mlPb04+J1ZN!PcRxvuUAI57T@y_l^2fN=u&ii@iDP zt)F*Q$=d1K0aI=Vo1?ZK7o#jnuU4E6dGgxhsf8Ku$#JXD&FAo)0zNz%-fvbK9Z(zB zC~xe%S*$F-Ro&j0+8F0wTN>SGhMVb|Zzy2@@=?-$O0T~Zx8H#G=*Moz944pf=}9to zj+~?W`pJyPZpfI`yN7;eMh~*v>-2fYo{cj7Y|lBtQ+vH^fOFKoqYk!mK99${M}tSt zR@-tR_H;6R?e2^VW%{lz6-Nc*f9(>U53LXAG7PX(*cv7t#41kz6ll3*9Thr!q1-Jj z*(WF=t=V<*3;+NCKmZ_L4}IaKFC4OudH<$MQV#;ar$Ij~0l=rhq?6_s%{KK6^%?+e zkN^Mz_}h0K>Osvfn%}5r{_5=^bqVT0Jc$P}#=`_06Zjw`3D0#Ogc_X?P^+kl@TanP z_OYrOxiN-3AI}5!Pyjd&8SD^lLJ=H^wZHL>iO`N<r7DV(1=Dz>A0vCvlVr z4yPc;*Wolctc7dgB6>z>BN%}a@w2h)1>pqr;Sf&ZJkEzxkU=(^j-Cfb7(fOf8fsKP zgaS&WARomjL=mbGgECa17`f7}3=)prfP93Z0t)0IAE79YqAL*v1*-H46c?+RfO?3? zftuE>L_E}}hN`fY2PZgSs@r9cIeRavF@*lMW2zzKHnfHT~Y=f;S+F(Qc( zqhnln?|K$th(Qd(z#1MRF^i0K20@K7)GJX3Q2EqOa0P=fQKm*Al;lJVA`yuo*nt6q i00Eey3e}+JqtXPdS)$#4YC`ln@5X*7q3OPn9tHq(rG04t diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index 1c41421910..68f0918301 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -4,6 +4,7 @@ // You may need to hard reload your browser window to see the changes. export default /** @type {const} */ ([ + 'auto_delete', 'book_5', 'brush', 'code', diff --git a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx index 9ebe33e065..54d098c6c8 100644 --- a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx @@ -9,8 +9,8 @@ import { useUserContext } from '@/shared/context/user-context' import { lazy, Suspense, useEffect } from 'react' import { useTranslation } from 'react-i18next' import classNames from 'classnames' -import { RailPanelHeader } from '../rail' import { RailIndicator } from '../rail-indicator' +import RailPanelHeader from '../rail-panel-header' const MessageList = lazy(() => import('../../../chat/components/message-list')) diff --git a/services/web/frontend/js/features/ide-redesign/components/errors.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx similarity index 56% rename from services/web/frontend/js/features/ide-redesign/components/errors.tsx rename to services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx index 2313022d3c..7b721a1d51 100644 --- a/services/web/frontend/js/features/ide-redesign/components/errors.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx @@ -1,9 +1,7 @@ -import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' -import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' -import { RailIndicator } from './rail-indicator' +import { RailIndicator } from '../rail-indicator' -export const ErrorIndicator = () => { +export default function ErrorIndicator() { const { logEntries } = useCompileContext() if (!logEntries) { @@ -25,11 +23,3 @@ export const ErrorIndicator = () => { /> ) } - -export const ErrorPane = () => { - return ( - - - - ) -} diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx new file mode 100644 index 0000000000..2f3a54b095 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from 'react-i18next' +import RailPanelHeader from '../rail-panel-header' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { + Dropdown, + DropdownMenu, + DropdownToggle, +} from '@/features/ui/components/bootstrap-5/dropdown-menu' +import PdfFileList from '@/features/pdf-preview/components/pdf-file-list' +import { forwardRef } from 'react' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' + +export default function ErrorLogsHeader() { + const { t } = useTranslation() + + return ( + , + , + ]} + /> + ) +} + +const ClearCacheButton = () => { + const { compiling, clearCache, clearingCache } = useCompileContext() + const { t } = useTranslation() + + return ( + + clearCache()} + className="rail-panel-header-button-subdued" + icon="auto_delete" + isLoading={clearingCache} + disabled={clearingCache || compiling} + accessibilityLabel={t('clear_cached_files')} + size="sm" + /> + + ) +} + +const DownloadFileDropdown = () => { + const { fileList } = useCompileContext() + + const { t } = useTranslation() + + return ( + + + {t('other_logs_and_files')} + + {fileList && ( + + + + )} + + ) +} + +const DownloadFileDropdownToggleButton = forwardRef< + HTMLButtonElement, + { onClick: React.MouseEventHandler } +>(function DownloadFileDropdownToggleButton({ onClick }, ref) { + const { compiling, fileList } = useCompileContext() + const { t } = useTranslation() + + return ( + + + + ) +}) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx new file mode 100644 index 0000000000..2cff048256 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx @@ -0,0 +1,14 @@ +import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' +import ErrorLogs from './error-logs' +import ErrorLogsHeader from './error-logs-header' + +export default function ErrorLogsPanel() { + return ( + +
+ + +
+
+ ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx new file mode 100644 index 0000000000..7b54785295 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from 'react-i18next' +import { memo, useMemo, useState } from 'react' +import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' +import StopOnFirstErrorPrompt from '@/features/pdf-preview/components/stop-on-first-error-prompt' +import PdfPreviewError from '@/features/pdf-preview/components/pdf-preview-error' +import PdfValidationIssue from '@/features/pdf-preview/components/pdf-validation-issue' +import PdfLogsEntries from '@/features/pdf-preview/components/pdf-logs-entries' +import PdfPreviewErrorBoundaryFallback from '@/features/pdf-preview/components/pdf-preview-error-boundary-fallback' +import withErrorBoundary from '@/infrastructure/error-boundary' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap' +import { LogEntry as LogEntryData } from '@/features/pdf-preview/util/types' +import LogEntry from './log-entry' + +type ErrorLogTab = { + key: string + label: string + entries: LogEntryData[] | undefined +} + +function ErrorLogs() { + const { error, logEntries, rawLog, validationIssues, stoppedOnFirstError } = + useCompileContext() + + const tabs = useMemo(() => { + return [ + { key: 'all', label: 'All', entries: logEntries?.all }, + { key: 'errors', label: 'Errors', entries: logEntries?.errors }, + { key: 'warnings', label: 'Warnings', entries: logEntries?.warnings }, + { key: 'info', label: 'Info', entries: logEntries?.typesetting }, + ] + }, [logEntries]) + + const { loadingError } = usePdfPreviewContext() + + const { t } = useTranslation() + + const [activeTab, setActiveTab] = useState('all') + + const entries = useMemo(() => { + return tabs.find(tab => tab.key === activeTab)?.entries || [] + }, [activeTab, tabs]) + + const includeErrors = activeTab === 'all' || activeTab === 'errors' + const includeWarnings = activeTab === 'all' || activeTab === 'warnings' + const includeInfo = activeTab === 'all' || activeTab === 'info' + + return ( + + + +
+ {stoppedOnFirstError && includeErrors && } + + {loadingError && ( + + )} + + {error && ( + + )} + + {includeErrors && + validationIssues && + Object.entries(validationIssues).map(([name, issue]) => ( + + ))} + + {entries && ( + 0} + /> + )} + + {rawLog && includeInfo && ( + + )} +
+
+
+ ) +} + +function formatErrorNumber(num: number | undefined) { + if (num === undefined) { + return undefined + } + + if (num > 99) { + return '99+' + } + + return Math.floor(num).toString() +} + +const TabHeader = ({ tab, active }: { tab: ErrorLogTab; active: boolean }) => { + return ( + + {tab.label} +
+ {/* TODO: it would be nice if this number included custom errors */} + {formatErrorNumber(tab.entries?.length)} +
+
+ ) +} + +export default withErrorBoundary(memo(ErrorLogs), () => ( + +)) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx new file mode 100644 index 0000000000..ff60fc63b9 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx @@ -0,0 +1,153 @@ +import classNames from 'classnames' +import { useState, useRef, MouseEventHandler, ElementType } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import { + ErrorLevel, + SourceLocation, + LogEntry as LogEntryData, +} from '@/features/pdf-preview/util/types' +import useResizeObserver from '@/features/preview/hooks/use-resize-observer' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' + +const actionComponents = importOverleafModules( + 'pdfLogEntryHeaderActionComponents' +) as { + import: { default: ElementType } + path: string +}[] + +function LogEntryHeader({ + sourceLocation, + level, + headerTitle, + logType, + showSourceLocationLink = true, + onSourceLocationClick, + collapsed, + onToggleCollapsed, + id, + logEntry, +}: { + headerTitle: string | React.ReactNode + level: ErrorLevel + logType?: string + sourceLocation?: SourceLocation + showSourceLocationLink?: boolean + onSourceLocationClick?: MouseEventHandler + collapsed: boolean + onToggleCollapsed: () => void + id?: string + logEntry?: LogEntryData +}) { + const { t } = useTranslation() + const logLocationSpanRef = useRef(null) + const [locationSpanOverflown, setLocationSpanOverflown] = useState(false) + + useResizeObserver( + logLocationSpanRef, + locationSpanOverflown, + checkLocationSpanOverflow + ) + + const file = sourceLocation ? sourceLocation.file : null + const line = sourceLocation ? sourceLocation.line : null + const logEntryHeaderTextClasses = classNames('log-entry-header-text', { + 'log-entry-header-text-error': level === 'error', + 'log-entry-header-text-warning': level === 'warning', + 'log-entry-header-text-info': + level === 'info' || level === 'typesetting' || level === 'raw', + 'log-entry-header-text-success': level === 'success', + }) + + function checkLocationSpanOverflow(observedElement: ResizeObserverEntry) { + const spanEl = observedElement.target + const isOverflowing = spanEl.scrollWidth > spanEl.clientWidth + setLocationSpanOverflown(isOverflowing) + } + + const locationText = + showSourceLocationLink && file ? `${file}${line ? `, ${line}` : ''}` : null + + // Because we want an ellipsis on the left-hand side (e.g. "...longfilename.tex"), the + // `log-entry-location` class has text laid out from right-to-left using the CSS + // rule `direction: rtl;`. + // This works most of the times, except when the first character of the filename is considered + // a punctuation mark, like `/` (e.g. `/foo/bar/baz.sty`). In this case, because of + // right-to-left writing rules, the punctuation mark is moved to the right-side of the string, + // resulting in `...bar/baz.sty/` instead of `...bar/baz.sty`. + // To avoid this edge-case, we wrap the `logLocationLinkText` in two directional formatting + // characters: + // * \u202A LEFT-TO-RIGHT EMBEDDING Treat the following text as embedded left-to-right. + // * \u202C POP DIRECTIONAL FORMATTING End the scope of the last LRE, RLE, RLO, or LRO. + // This essentially tells the browser that, althought the text is laid out from right-to-left, + // the wrapped portion of text should follow left-to-right writing rules. + const formattedLocationText = locationText ? ( + + {`\u202A${locationText}\u202C`} + + ) : null + + const headerTitleText = logType ? `${logType} ${headerTitle}` : headerTitle + + return ( +
+ + + +
+

{headerTitleText}

+ {locationSpanOverflown && formattedLocationText && locationText ? ( + + {formattedLocationText} + + ) : ( + formattedLocationText + )} +
+
+ {showSourceLocationLink && ( + + + + )} + {actionComponents.map(({ import: { default: Component }, path }) => ( + + ))} +
+
+ ) +} + +export default LogEntryHeader diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx new file mode 100644 index 0000000000..0986343131 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx @@ -0,0 +1,109 @@ +import { memo, MouseEventHandler, useCallback, useState } from 'react' +import HumanReadableLogsHints from '../../../../ide/human-readable-logs/HumanReadableLogsHints' +import { sendMB } from '@/infrastructure/event-tracking' +import { + ErrorLevel, + LogEntry as LogEntryData, + SourceLocation, +} from '@/features/pdf-preview/util/types' +import LogEntryHeader from './log-entry-header' +import PdfLogEntryContent from '@/features/pdf-preview/components/pdf-log-entry-content' + +function LogEntry({ + ruleId, + headerTitle, + rawContent, + logType, + formattedContent, + extraInfoURL, + level, + sourceLocation, + showSourceLocationLink = true, + entryAriaLabel = undefined, + contentDetails, + onSourceLocationClick, + index, + logEntry, + id, + alwaysExpandRawContent = false, +}: { + headerTitle: string | React.ReactNode + level: ErrorLevel + ruleId?: string + rawContent?: string + logType?: string + formattedContent?: React.ReactNode + extraInfoURL?: string | null + sourceLocation?: SourceLocation + showSourceLocationLink?: boolean + entryAriaLabel?: string + contentDetails?: string[] + onSourceLocationClick?: (sourceLocation: SourceLocation) => void + index?: number + logEntry?: LogEntryData + id?: string + alwaysExpandRawContent?: boolean +}) { + const [collapsed, setCollapsed] = useState(true) + + if (ruleId && HumanReadableLogsHints[ruleId]) { + const hint = HumanReadableLogsHints[ruleId] + formattedContent = hint.formattedContent(contentDetails) + extraInfoURL = hint.extraInfoURL + } + + const handleLogEntryLinkClick: MouseEventHandler = + useCallback( + event => { + event.preventDefault() + + if (onSourceLocationClick && sourceLocation) { + onSourceLocationClick(sourceLocation) + + const parts = sourceLocation?.file?.split('.') + const extension = + parts?.length && parts?.length > 1 ? parts.pop() : '' + sendMB('log-entry-link-click', { level, ruleId, extension }) + } + }, + [level, onSourceLocationClick, ruleId, sourceLocation] + ) + + return ( +
+ setCollapsed(collapsed => !collapsed)} + id={id} + logEntry={logEntry} + /> + + {!collapsed && ( + <> +
+ + + )} +
+ ) +} + +export default memo(LogEntry) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx new file mode 100644 index 0000000000..7794747d30 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx @@ -0,0 +1,10 @@ +import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' +import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' + +export default function OldErrorPane() { + return ( + + + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx index d1e4358907..e477602e3e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx @@ -1,7 +1,7 @@ import { ElementType } from 'react' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' -import { RailPanelHeader } from '../rail' import { useTranslation } from 'react-i18next' +import RailPanelHeader from '../rail-panel-header' const integrationPanelComponents = importOverleafModules( 'integrationPanelComponents' diff --git a/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx b/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx new file mode 100644 index 0000000000..94ac2f42af --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { useRailContext } from '../contexts/rail-context' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import React from 'react' + +export default function RailPanelHeader({ + title, + actions, +}: { + title: string + actions?: React.ReactNode[] +}) { + const { t } = useTranslation() + const { handlePaneCollapse } = useRailContext() + return ( +
+

{title}

+ +
+ {actions} + +
+
+ ) +} 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 9bd70ac4bb..5edcba55a9 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -6,7 +6,7 @@ import MaterialIcon, { } from '@/shared/components/material-icon' import { Panel } from 'react-resizable-panels' import { useLayoutContext } from '@/shared/context/layout-context' -import { ErrorIndicator, ErrorPane } from './errors' +import ErrorIndicator from './error-logs/error-indicator' import { RailModalKey, RailTabKey, @@ -39,6 +39,10 @@ import { hasFullProjectSearch, } from './full-project-search-panel' import { sendSearchEvent } from '@/features/event-tracking/search-events' +import ErrorLogsPanel from './error-logs/error-logs-panel' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import OldErrorPane from './error-logs/old-error-pane' +import { useFeatureFlag } from '@/shared/context/split-test-context' type RailElement = { icon: AvailableUnfilledIcon @@ -47,6 +51,7 @@ type RailElement = { indicator?: ReactElement title: string hide?: boolean + disabled?: boolean } type RailActionButton = { @@ -96,6 +101,8 @@ export const RailLayout = () => { togglePane, setResizing, } = useRailContext() + const { logEntries } = useCompileContext() + const errorLogsDisabled = !logEntries const { view, setLeftMenuShown } = useLayoutContext() @@ -103,6 +110,8 @@ export const RailLayout = () => { const isHistoryView = view === 'history' + const newErrorlogs = useFeatureFlag('new-editor-error-logs-redesign') + const railTabs: RailElement[] = useMemo( () => [ { @@ -142,11 +151,12 @@ export const RailLayout = () => { key: 'errors', icon: 'report', title: t('error_log'), - component: , + component: newErrorlogs ? : , indicator: , + disabled: errorLogsDisabled, }, ], - [t] + [t, errorLogsDisabled, newErrorlogs] ) const railActions: RailAction[] = useMemo( @@ -217,7 +227,7 @@ export const RailLayout = () => {
Date: Wed, 4 Jun 2025 10:47:18 +0100 Subject: [PATCH 042/209] Merge pull request #26100 from overleaf/dp-compile-timeout-paywall Add compile timeout paywall to new editor GitOrigin-RevId: 9742ae67b4103c72cc9d87852801ae8751f85d6d --- .../web/frontend/extracted-translations.json | 2 + .../pdf-preview/pdf-error-state.tsx | 112 ++++++++++++++---- .../pages/editor/pdf-error-state.scss | 13 +- services/web/locales/en.json | 2 + 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cad43ed4e1..fda4b6368b 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -287,6 +287,8 @@ "compile_error_entry_description": "", "compile_error_handling": "", "compile_larger_projects": "", + "compile_limit_reached": "", + "compile_limit_upgrade_prompt": "", "compile_mode": "", "compile_terminated_by_user": "", "compiler": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx index ef77c0fa5d..a4f53ae614 100644 --- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx @@ -5,31 +5,37 @@ import { useRailContext } from '../../contexts/rail-context' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' import { useIsNewEditorEnabled } from '../../utils/new-editor-utils' +import { upgradePlan } from '@/main/account-upgrade' +import classNames from 'classnames' function PdfErrorState() { const { loadingError } = usePdfPreviewContext() // TODO ide-redesign-cleanup: rename showLogs to something else and check usages - const { showLogs } = useCompileContext() - const { t } = useTranslation() - const { openTab: openRailTab } = useRailContext() + const { hasShortCompileTimeout, error, showLogs } = useCompileContext() const newEditor = useIsNewEditorEnabled() if (!newEditor || (!loadingError && !showLogs)) { return null } + if (hasShortCompileTimeout && error === 'timedout') { + return + } + + return +} + +const GeneralErrorState = () => { + const { t } = useTranslation() + const { openTab: openRailTab } = useRailContext() + return ( -
-
-
- -
-
-

{t('pdf_couldnt_compile')}

-

- {t('we_are_unable_to_generate_the_pdf_at_this_time')} -

-
+ {t('check_logs')} -
-
-
- - {t('why_might_this_happen')} + } + extraContent={ +
+
+ + {t('why_might_this_happen')} +
+
    +
  • {t('there_is_an_unrecoverable_latex_error')}
  • +
  • {t('the_document_environment_contains_no_content')}
  • +
  • {t('this_project_contains_a_file_called_output')}
  • +
-
    -
  • {t('there_is_an_unrecoverable_latex_error')}
  • -
  • {t('the_document_environment_contains_no_content')}
  • -
  • {t('this_project_contains_a_file_called_output')}
  • -
-
-
+ } + /> ) } +const CompileTimeoutErrorState = () => { + const { t } = useTranslation() + + return ( + upgradePlan('compile-timeout')} + > + {t('upgrade')} + + } + /> + ) +} + +const ErrorState = ({ + title, + description, + iconType, + actions, + iconClassName, + extraContent, +}: { + title: string + description: string + iconType: string + actions: React.ReactNode + iconClassName?: string + extraContent?: React.ReactNode +}) => { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
+ {actions} +
+ {extraContent} +
+ ) +} export default PdfErrorState diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss index c1da6ab431..aee036f775 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss @@ -33,9 +33,7 @@ padding: 0 var(--spacing-09) var(--spacing-09) var(--spacing-09); } -.pdf-error-state-warning-icon { - background-color: var(--bg-danger-03); - color: var(--content-danger); +.pdf-error-state-icon { width: 80px; height: 80px; display: flex; @@ -43,6 +41,15 @@ justify-content: center; border-radius: 100%; + .material-symbols { + font-size: 80px; + } +} + +.pdf-error-state-warning-icon { + background-color: var(--bg-danger-03); + color: var(--content-danger); + .material-symbols { font-size: 32px; } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index daa2317683..4404e57553 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -370,6 +370,8 @@ "compile_error_entry_description": "An error which prevented this project from compiling", "compile_error_handling": "Compile error handling", "compile_larger_projects": "Compile larger projects", + "compile_limit_reached": "Compile limit reached", + "compile_limit_upgrade_prompt": "Your document took longer than the free plan’s compile window. Upgrade to Overleaf Premium for extended compile durations, priority build servers, and uninterrupted LaTeX processing—so you can focus on writing, not waiting.", "compile_mode": "Compile mode", "compile_servers": "Compile servers", "compile_servers_info_new": "The servers used to compile your project. Compiles for users on paid plans always run on the fastest available servers.", From 62714d995dcaa6044ddc2c7791b9a5d9646fcaf7 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 4 Jun 2025 15:52:21 +0200 Subject: [PATCH 043/209] Revert "Reinitialise Writefull toolbar after buying AI assist (#25741)" (#26144) This reverts commit 7247ae45ca7de7f1f3778b1b22f49e2ff840a7ef. GitOrigin-RevId: c6dc1a073ce3d0f9703e426df1c12fa1c7ffac5c --- .../web/frontend/js/shared/context/types/writefull-instance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/frontend/js/shared/context/types/writefull-instance.ts b/services/web/frontend/js/shared/context/types/writefull-instance.ts index 18b6d08616..120590e668 100644 --- a/services/web/frontend/js/shared/context/types/writefull-instance.ts +++ b/services/web/frontend/js/shared/context/types/writefull-instance.ts @@ -42,5 +42,4 @@ export interface WritefullAPI { ): void openTableGenerator(): void openEquationGenerator(): void - refreshSession(): void } From e3310e2358e5f59b14ee6c1d639c6e8570345ec3 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 4 Jun 2025 07:55:45 -0700 Subject: [PATCH 044/209] Merge pull request #26117 from overleaf/sg-money-back-wording Update en.json GitOrigin-RevId: 5b02970e6344b65e37c49c196c9e3c89b1555c75 --- services/web/locales/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4404e57553..78ea2d6463 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -260,7 +260,7 @@ "can_view_content": "Can view content", "cancel": "Cancel", "cancel_add_on": "Cancel add-on", - "cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll give you your money back, no questions asked, if you let us know within 30 days.", + "cancel_anytime": "We’re confident that you’ll love __appName__, but if not, you can cancel anytime and request your money back, hassle free, within 30 days.", "cancel_my_account": "Cancel my subscription", "cancel_my_subscription": "Cancel my subscription", "cancel_personal_subscription_first": "You already have an individual subscription, would you like us to cancel this first before joining the group licence?", @@ -1349,7 +1349,7 @@ "missing_field_for_entry": "Missing field for", "missing_fields_for_entry": "Missing fields for", "missing_payment_details": "Missing payment details", - "money_back_guarantee": "30-day money back guarantee, no questions asked", + "money_back_guarantee": "30-day money back guarantee, hassle free", "month": "month", "month_plural": "months", "monthly": "Monthly", From ca109044849eda9c4c86fed187783e7300e5a017 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 4 Jun 2025 07:56:01 -0700 Subject: [PATCH 045/209] Merge pull request #26027 from overleaf/mf-admin-panel-stripe [web] Update admin panel with Stripe subscription data GitOrigin-RevId: fc4f773c5d6d2eae206a791c1ad40d8ccbf766e7 --- services/web/frontend/js/utils/meta.ts | 13 +++++++++++++ services/web/types/admin/subscription.ts | 14 +++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 2e8df94273..1dd4af88e0 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -54,6 +54,7 @@ import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-na import { FooterMetadata } from '@/features/ui/components/types/footer-metadata' import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log' import { ActiveExperiment } from './labs-utils' +import { Subscription as AdminSubscription } from '../../../types/admin/subscription' export interface Meta { 'ol-ExposedSettings': ExposedSettings @@ -61,6 +62,7 @@ export interface Meta { string, { annual: string; monthly: string; annualDividedByTwelve: string } > + 'ol-adminSubscription': AdminSubscription 'ol-aiAssistViaWritefullSource': string 'ol-allInReconfirmNotificationPeriods': UserEmailData[] 'ol-allowedExperiments': string[] @@ -201,6 +203,16 @@ export interface Meta { 'ol-recommendedCurrency': CurrencyCode 'ol-reconfirmationRemoveEmail': string 'ol-reconfirmedViaSAML': string + 'ol-recurlyAccount': + | { + code: string + error?: undefined + } + | { + error: boolean + code?: undefined + } + | undefined 'ol-recurlyApiKey': string 'ol-recurlySubdomain': string 'ol-ro-mirror-on-client-no-local-storage': boolean @@ -229,6 +241,7 @@ export interface Meta { 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string 'ol-stripeApiKey': string + 'ol-stripeCustomerId': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index bbcdd3b953..ad05fbac40 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -1,7 +1,15 @@ -import { GroupPolicy } from '../subscription/dashboard/subscription' +import { + GroupPolicy, + PaymentProvider, +} from '../subscription/dashboard/subscription' import { SSOConfig } from '../subscription/sso' import { TeamInvite } from '../team-invite' +type RecurlyAdminClientPaymentProvider = Record +type StripeAdminClientPaymentProvider = PaymentProvider & { + service: 'stripe' +} + export type Subscription = { _id: string teamInvites: TeamInvite[] @@ -13,4 +21,8 @@ export type Subscription = { managedUsersEnabled: boolean v1_id: number salesforce_id: string + recurlySubscription_id?: string + paymentProvider: + | RecurlyAdminClientPaymentProvider + | StripeAdminClientPaymentProvider } From cd10a31a16fd0d982583d37195c20c756d309e28 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 4 Jun 2025 17:33:05 +0200 Subject: [PATCH 046/209] [server-ce] fix direct invocation of create-user.mjs script in web (#26152) GitOrigin-RevId: 9c7917e489dc8f3651f4ccf88a740ad60b6b4437 --- .../modules/server-ce-scripts/scripts/create-user.mjs | 10 ++++++++++ .../test/acceptance/src/ServerCEScriptsTests.mjs | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/services/web/modules/server-ce-scripts/scripts/create-user.mjs b/services/web/modules/server-ce-scripts/scripts/create-user.mjs index 219578b4b0..7c29ca7f5f 100644 --- a/services/web/modules/server-ce-scripts/scripts/create-user.mjs +++ b/services/web/modules/server-ce-scripts/scripts/create-user.mjs @@ -48,3 +48,13 @@ Please visit the following URL to set a password for ${email} and log in: ) }) } + +if (filename === process.argv[1]) { + try { + await main() + process.exit(0) + } catch (error) { + console.error({ error }) + process.exit(1) + } +} 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 a02e9a0e68..f8d458ff6b 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 @@ -99,6 +99,14 @@ describe('ServerCEScripts', function () { expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false }) }) + it('should also work with mjs version', async function () { + const out = run( + 'node modules/server-ce-scripts/scripts/create-user.mjs --email=foo@bar.com' + ) + expect(out).to.include('/user/activate?token=') + expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false }) + }) + it('should create an admin user with --admin flag', async function () { run( 'node modules/server-ce-scripts/scripts/create-user.js --admin --email=foo@bar.com' From 0037b0b3fc0290954e50e751dc995449e0a54b65 Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Thu, 5 Jun 2025 01:04:13 +0000 Subject: [PATCH 047/209] auto update translation GitOrigin-RevId: f0b783bc74dc2212d330305600c8f3d16d27eef3 --- services/web/locales/da.json | 2 +- services/web/locales/de.json | 1 + services/web/locales/fr.json | 1 + services/web/locales/sv.json | 1 + services/web/locales/zh-CN.json | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index a984ea8605..3d8b52e547 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -432,6 +432,7 @@ "disconnected": "Forbindelsen blev afbrudt", "discount_of": "Rabat på __amount__", "discover_latex_templates_and_examples": "Opdag LaTeX skabeloner og eksempler til at hjælpe med alt fra at skrive en artikel til at bruge en specifik LaTeX pakke.", + "dismiss_error_popup": "Afvis første fejlmeddelelse", "display_deleted_user": "Vis slettede brugere", "do_not_have_acct_or_do_not_want_to_link": "Hvis du ikke har en __appName__-konto, eller hvis du ikke vil kæde den sammen med din __institutionName__-konto, klik venligst __clickText__.", "do_not_link_accounts": "Kæd ikke kontoer sammen", @@ -1522,7 +1523,6 @@ "resend": "Gensend", "resend_confirmation_code": "Gensend bekræftelseskode", "resend_confirmation_email": "Gensend bekræftelsesmail", - "resend_email": "Gensend e-mail", "resend_group_invite": "Gensend gruppeinvitation", "resend_link_sso": "Gensend SSO invitation", "resend_managed_user_invite": "Gensend invitation til styrede brugere", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index a6d68345ba..11129073df 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -312,6 +312,7 @@ "disable_stop_on_first_error": "„Anhalten beim ersten Fehler“ deaktivieren", "disconnected": "Nicht verbunden", "discount_of": "__amount__ Rabatt", + "dismiss_error_popup": "Erste Fehlermeldung schließen", "do_not_have_acct_or_do_not_want_to_link": "Wenn du kein __appName__-Konto hast oder nicht mit deinem __institutionName__-Konto verknüpfen möchtest, klicke auf „__clickText__“.", "do_not_link_accounts": "Konten nicht verknüpfen", "do_you_want_to_change_your_primary_email_address_to": "Willst Du deine primäre E-Mail-Adresse in __email__ ändern?", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 2e80ea2132..c081b84651 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -344,6 +344,7 @@ "disable_stop_on_first_error": "Désactiver “Arrêter à la première erreur”", "disconnected": "Déconnecté", "discount_of": "Remise de __amount__", + "dismiss_error_popup": "Ignorer l’alerte de première erreur", "do_not_have_acct_or_do_not_want_to_link": "Si vous n’avez pas de compte __appName__ ou si vous ne souhaitez pas le lier à votre compte __institutionName__, veuillez cliquer __clickText__.", "do_not_link_accounts": "Ne pas lier les comptes", "do_you_want_to_change_your_primary_email_address_to": "Voulez-vous définir __email__ comme votre adresse email principale ?", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index ab9f615050..9ed626fe36 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -208,6 +208,7 @@ "dictionary": "Ordbok", "disable_stop_on_first_error": "Inaktivera \"Stopp vid första fel\"", "disconnected": "Frånkopplad", + "dismiss_error_popup": "Avfärda varning om första fel", "do_not_have_acct_or_do_not_want_to_link": "Om du inte har ett __appName__-konto, eller om du inte vill länka till ditt __institutionName__-konto, vänligen klicka på __clickText__.", "do_not_link_accounts": "Länka ej konton", "documentation": "Dokumentation", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index e4704f2e9d..44e303d64d 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -518,6 +518,7 @@ "discover_latex_templates_and_examples": "探索 LaTeX 模板和示例,以帮助完成从撰写期刊文章到使用特定 LaTeX 包的所有工作。", "discover_the_fastest_way_to_search_and_cite": "探索搜索和引用的最快方法", "discover_why_over_people_worldwide_trust_overleaf": "了解为什么全世界有超过__count__万人信任 Overleaf 并把工作交给它。", + "dismiss_error_popup": "忽略第一个错误提示", "display": "显示", "display_deleted_user": "显示已删除的用户", "display_math": "显示数学公式", @@ -1791,7 +1792,6 @@ "resend": "重发", "resend_confirmation_code": "重新发送确认码", "resend_confirmation_email": "重新发送确认电子邮件", - "resend_email": "重新发送电子邮件", "resend_group_invite": "重新发送群组邀请", "resend_link_sso": "重新发送 SSO 邀请", "resend_managed_user_invite": "重新发送托管用户邀请", From 11e410c9c03bfc91737e176d35f2760cb4b7182b Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Thu, 5 Jun 2025 09:28:57 +0200 Subject: [PATCH 048/209] Merge pull request #26163 from overleaf/revert-25745-ac-bs5-metrics-module Revert "[web] Migrate metrics module Pug files to Bootstrap 5 (#25745)" GitOrigin-RevId: b97eecc2232f56833391fb789902f9a85936c365 --- services/web/.prettierignore | 2 +- .../frontend/stylesheets/app/admin-hub.less | 156 ++ .../institution-hub.less} | 28 +- .../metrics/metrics.scss => app/metrics.less} | 58 +- .../web/frontend/stylesheets/app/portals.less | 34 - .../publisher-hub.less} | 36 +- .../stylesheets/bootstrap-5/modules/all.scss | 7 - .../modules/metrics/admin-hub.scss | 93 -- .../modules/metrics/daterange-picker.scss | 617 -------- .../bootstrap-5/pages/admin/admin.scss | 5 - .../bootstrap-5/pages/project-list.scss | 2 +- .../components/daterange-picker.less | 656 ++++++++ .../nvd3.scss => components/nvd3.less} | 1368 +++++++++-------- .../nvd3_override.less} | 5 +- .../web/frontend/stylesheets/main-style.less | 7 + 15 files changed, 1557 insertions(+), 1517 deletions(-) create mode 100644 services/web/frontend/stylesheets/app/admin-hub.less rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/institution-hub.scss => app/institution-hub.less} (52%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/metrics.scss => app/metrics.less} (78%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/publisher-hub.scss => app/publisher-hub.less} (52%) delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss create mode 100644 services/web/frontend/stylesheets/components/daterange-picker.less rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/nvd3.scss => components/nvd3.less} (78%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/nvd3_override.scss => components/nvd3_override.less} (74%) diff --git a/services/web/.prettierignore b/services/web/.prettierignore index 39282c64c2..f4be187b87 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -6,7 +6,7 @@ frontend/js/vendor modules/**/frontend/js/vendor public/js public/minjs -frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss +frontend/stylesheets/components/nvd3.less frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs diff --git a/services/web/frontend/stylesheets/app/admin-hub.less b/services/web/frontend/stylesheets/app/admin-hub.less new file mode 100644 index 0000000000..bae3312447 --- /dev/null +++ b/services/web/frontend/stylesheets/app/admin-hub.less @@ -0,0 +1,156 @@ +.hub-header { + h2 { + display: inline-block; + } + a { + color: @ol-dark-green; + } + i { + font-size: 30px; + } + .dropdown { + margin-right: 10px; + } +} +.admin-item { + position: relative; + margin-bottom: 60px; + .section-title { + text-transform: capitalize; + } + .alert-danger { + color: @ol-red; + } +} +.hidden-chart-section { + display: none; +} +.hub-circle { + display: inline-block; + background-color: @accent-color-secondary; + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + //padding-top: 160px / 6.4; + img { + height: 160px - 160px / 3.2; + } + padding-top: 50px; + color: white; +} +.hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; +} +.hub-big-number { + float: left; + font-size: 32px; + font-weight: 900; + line-height: 40px; + color: @accent-color-secondary; +} +.hub-big-number, +.hub-number-label { + display: block; +} +.hub-metric-link { + position: absolute; + top: 9px; + right: 0; + a { + color: @accent-color-secondary; + } + i { + margin-right: 5px; + } +} +.custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + .chart-center-text { + font-family: @font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: @accent-color-secondary; + text-anchor: middle; + } + + .nv-legend-text { + font-family: @font-family-sans-serif; + font-size: 14px; + } +} +.chart-no-center-text { + .chart-center-text { + display: none; + } +} + +.superscript { + font-size: @font-size-large; +} + +.admin-page { + summary { + // firefox does not show markers for block items + display: list-item; + } +} + +.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; + + &:before { + background: rgb(0, 0, 0); + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); + border-radius: 8px; + content: ''; + height: 16px; + margin-top: -2px; + position: absolute; + opacity: 0.3; + transition: all 0.2s ease-in-out; + width: 40px; + } + + &:after { + background: rgb(255, 255, 255); + border-radius: 16px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + content: ''; + height: 24px; + left: -4px; + margin-top: -2px; + position: absolute; + top: -4px; + transition: all 0.2s ease-in-out; + width: 24px; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss b/services/web/frontend/stylesheets/app/institution-hub.less similarity index 52% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss rename to services/web/frontend/stylesheets/app/institution-hub.less index 67cbe580e4..cb705e4b99 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss +++ b/services/web/frontend/stylesheets/app/institution-hub.less @@ -1,54 +1,38 @@ #institution-hub { - .section-header { + .section_header { .dropdown { - margin-right: var(--spacing-04); + margin-right: 10px; } } #usage { .recent-activity { .overbox { - @include body-base; + font-size: 16px; } - .hub-big-number, .hub-number-label, .worked-on { display: block; width: 50%; } - .hub-big-number { - padding-right: var(--spacing-04); + padding-right: 10px; text-align: right; } - .hub-number-label, .worked-on { float: right; } - .hub-number-label { &:nth-child(odd) { - margin-top: var(--spacing-06); + margin-top: 16px; } } - .worked-on { - color: var(--content-secondary); + color: @text-small-color; font-style: italic; } } } - - .overbox { - margin: 0; - padding: var(--spacing-10) var(--spacing-07); - background: var(--white); - border: 1px solid var(--content-disabled); - - &.overbox-small { - padding: var(--spacing-04); - } - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss b/services/web/frontend/stylesheets/app/metrics.less similarity index 78% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss rename to services/web/frontend/stylesheets/app/metrics.less index 32cde9c522..5256b8a8bd 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss +++ b/services/web/frontend/stylesheets/app/metrics.less @@ -1,6 +1,6 @@ #metrics { max-width: none; - padding: 0 var(--spacing-09); + padding: 0 30px; width: auto; svg.nvd3-svg { @@ -9,18 +9,17 @@ .overbox { margin: 0; - padding: var(--spacing-10) var(--spacing-07); + padding: 40px 20px; background: #fff; border: 1px solid #dfdfdf; - .box { - padding-bottom: var(--spacing-09); + padding-bottom: 30px; overflow: hidden; - margin-bottom: var(--spacing-10); - border-bottom: 1px solid rgb(216 216 216); + margin-bottom: 40px; + border-bottom: 1px solid rgb(216, 216, 216); .header { - margin-bottom: var(--spacing-07); + margin-bottom: 20px; h4 { font-size: 19px; @@ -28,14 +27,10 @@ } } } - - &.overbox-small { - padding: var(--spacing-04); - } } .print-button { - margin-right: var(--spacing-04); + margin-right: 10px; font-size: 20px; } @@ -45,17 +40,21 @@ } .metric-col { - padding: var(--spacing-06); + padding: 15px; + } + + .metric-header-container { + h4 { + margin-bottom: 0; + } } svg { display: block; height: 250px; - text { font-family: 'Open Sans', sans-serif; } - &:not(:root) { overflow: visible; } @@ -80,10 +79,6 @@ // BEGIN: Metrics header .metric-header-container { - h4 { - margin-bottom: 0; - } - > h4 { margin-top: 0; margin-bottom: 0; @@ -94,14 +89,12 @@ font-size: 0.5em; } } - // END: Metrics header // BEGIN: Metrics footer .metric-footer-container { text-align: center; } - // END: Metrics footer // BEGIN: Metrics overlays @@ -114,7 +107,7 @@ height: 100%; width: 100%; padding: 16px; /* 15px of .metric-col padding + 1px border */ - padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding */ + padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/ } .metric-overlay-loading { @@ -136,20 +129,19 @@ width: 100%; height: 100%; } - // END: Metrics overlays } #metrics-header { - @include media-breakpoint-up(lg) { - margin-bottom: var(--spacing-09); + @media (min-width: 1200px) { + margin-bottom: 30px; } h3 { display: inline-block; } - .section-header { + .section_header { margin-bottom: 0; } @@ -170,11 +162,9 @@ #dates-container { display: inline-block; - .daterangepicker { - margin-right: var(--spacing-06); + margin-right: 15px; } - #metrics-dates { padding: 0; } @@ -182,10 +172,14 @@ } #metrics-footer { - margin-top: var(--spacing-09); + margin-top: 30px; text-align: center; } -body.print-loading #metrics .metric-col { - opacity: 0.5; +body.print-loading { + #metrics { + .metric-col { + opacity: 0.5; + } + } } diff --git a/services/web/frontend/stylesheets/app/portals.less b/services/web/frontend/stylesheets/app/portals.less index b69176b05f..9dfd4a57b7 100644 --- a/services/web/frontend/stylesheets/app/portals.less +++ b/services/web/frontend/stylesheets/app/portals.less @@ -141,38 +141,4 @@ } } } - .hub-circle { - display: inline-block; - background-color: @green-70; - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - padding-top: 50px; - color: white; - } - .hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; - } - .custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - .chart-center-text { - font-family: @font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: @green-70; - text-anchor: middle; - } - - .nv-legend-text { - font-family: @font-family-sans-serif; - font-size: 14px; - } - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss b/services/web/frontend/stylesheets/app/publisher-hub.less similarity index 52% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss rename to services/web/frontend/stylesheets/app/publisher-hub.less index f59b33e6ef..8d7e5ea7eb 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss +++ b/services/web/frontend/stylesheets/app/publisher-hub.less @@ -2,66 +2,48 @@ .recent-activity { .hub-big-number { text-align: right; - padding-right: var(--spacing-06); + padding-right: 15px; } } #templates-container { width: 100%; - tr { - border: 1px solid var(--bg-light-secondary); + border: 1px solid @ol-blue-gray-0; } - td { - padding: var(--spacing-06); + padding: 15px; } - td:last-child { text-align: right; } - .title-cell { max-width: 300px; } - .title-text { font-weight: bold; } - .hub-big-number { width: 60%; - padding-right: var(--spacing-04); - padding-top: var(--spacing-04); + padding-right: 10px; + padding-top: 10px; text-align: right; } - .hub-number-label, .since { width: 35%; float: right; - - @include media-breakpoint-down(md) { + @media screen and (max-width: 940px) { float: none; } } - .hub-long-big-number { - padding-right: var(--spacing-10); + padding-right: 40px; } - .created-on { - @include body-sm; - - color: var(--content-disabled); + color: @gray-light; font-style: italic; + font-size: 14px; } } - - .overbox { - margin: 0; - padding: var(--spacing-10) var(--spacing-07); - background: var(--white); - border: 1px solid var(--content-disabled); - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss index 01d58c8c20..b92eb80551 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss @@ -1,10 +1,3 @@ -@import 'metrics/admin-hub'; -@import 'metrics/daterange-picker'; -@import 'metrics/institution-hub'; -@import 'metrics/metrics'; -@import 'metrics/nvd3'; -@import 'metrics/nvd3_override'; -@import 'metrics/publisher-hub'; @import 'third-party-references'; @import 'symbol-palette'; @import 'writefull'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss deleted file mode 100644 index 3e6576cf92..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss +++ /dev/null @@ -1,93 +0,0 @@ -.hub-header { - h2 { - display: inline-block; - } - - .dropdown { - margin-right: var(--spacing-04); - } -} - -.admin-item { - position: relative; - margin-bottom: var(--spacing-12); - - .section-title { - text-transform: capitalize; - } - - .alert-danger { - color: var(--content-danger); - } -} - -.hidden-chart-section { - display: none; -} - -.hub-circle { - display: inline-block; - background-color: var(--green-70); - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - padding-top: 50px; - color: white; -} - -.hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; -} - -.hub-big-number { - float: left; - font-size: 32px; - font-weight: 900; - line-height: 40px; - color: var(--green-70); -} - -.hub-big-number, -.hub-number-label { - display: block; -} - -.hub-metric-link { - position: absolute; - top: 9px; - right: 0; - - i { - margin-right: 5px; - } -} - -.custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - - .chart-center-text { - font-family: $font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: var(--green-70); - text-anchor: middle; - } - - .nv-legend-text { - font-family: $font-family-sans-serif; - font-size: 14px; - } -} - -.chart-no-center-text { - .chart-center-text { - display: none; - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss deleted file mode 100644 index 33e466bd91..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss +++ /dev/null @@ -1,617 +0,0 @@ -// A stylesheet for use with Bootstrap 3.x -// @author: Dan Grossman http://www.dangrossman.info/ -// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. -// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php -// @website: https://www.improvely.com/ - -/* stylelint-disable selector-class-pattern */ - -// VARIABLES - -// Settings - -// The class name to contain everything within. -$arrow-size: 7px; - -// Colors -$daterangepicker-color: $green-50; -$daterangepicker-bg-color: #fff; -$daterangepicker-cell-color: $daterangepicker-color; -$daterangepicker-cell-border-color: transparent; -$daterangepicker-cell-bg-color: $daterangepicker-bg-color; -$daterangepicker-cell-hover-color: $daterangepicker-color; -$daterangepicker-cell-hover-border-color: $daterangepicker-cell-border-color; -$daterangepicker-cell-hover-bg-color: #eee; -$daterangepicker-in-range-color: #000; -$daterangepicker-in-range-border-color: transparent; -$daterangepicker-in-range-bg-color: #ebf4f8; -$daterangepicker-active-color: #fff; -$daterangepicker-active-bg-color: #138a07; -$daterangepicker-active-border-color: transparent; -$daterangepicker-unselected-color: #999; -$daterangepicker-unselected-border-color: transparent; -$daterangepicker-unselected-bg-color: #fff; - -// daterangepicker -$daterangepicker-width: 278px; -$daterangepicker-padding: 4px; -$daterangepicker-z-index: 3000; -$daterangepicker-border-size: 1px; -$daterangepicker-border-color: #ccc; -$daterangepicker-border-radius: 4px; - -// Calendar -$daterangepicker-calendar-margin: $daterangepicker-padding; -$daterangepicker-calendar-bg-color: $daterangepicker-bg-color; -$daterangepicker-calendar-border-size: 1px; -$daterangepicker-calendar-border-color: $daterangepicker-bg-color; -$daterangepicker-calendar-border-radius: $daterangepicker-border-radius; - -// Calendar Cells -$daterangepicker-cell-size: 20px; -$daterangepicker-cell-width: $daterangepicker-cell-size; -$daterangepicker-cell-height: $daterangepicker-cell-size; -$daterangepicker-cell-border-radius: $daterangepicker-calendar-border-radius; -$daterangepicker-cell-border-size: 1px; - -// Dropdowns -$daterangepicker-dropdown-z-index: $daterangepicker-z-index + 1; - -// Controls -$daterangepicker-control-height: 30px; -$daterangepicker-control-line-height: $daterangepicker-control-height; -$daterangepicker-control-color: #555; -$daterangepicker-control-border-size: 1px; -$daterangepicker-control-border-color: #ccc; -$daterangepicker-control-border-radius: 4px; -$daterangepicker-control-active-border-size: 1px; -$daterangepicker-control-active-border-color: $green-50; -$daterangepicker-control-active-border-radius: $daterangepicker-control-border-radius; -$daterangepicker-control-disabled-color: #ccc; - -// Ranges -$daterangepicker-ranges-color: $green-50; -$daterangepicker-ranges-bg-color: daterangepicker-ranges-color; -$daterangepicker-ranges-border-size: 1px; -$daterangepicker-ranges-border-color: $daterangepicker-ranges-bg-color; -$daterangepicker-ranges-border-radius: $daterangepicker-border-radius; -$daterangepicker-ranges-hover-color: #fff; -$daterangepicker-ranges-hover-bg-color: $daterangepicker-ranges-color; -$daterangepicker-ranges-hover-border-size: $daterangepicker-ranges-border-size; -$daterangepicker-ranges-hover-border-color: $daterangepicker-ranges-hover-bg-color; -$daterangepicker-ranges-hover-border-radius: $daterangepicker-border-radius; -$daterangepicker-ranges-active-border-size: $daterangepicker-ranges-border-size; -$daterangepicker-ranges-active-border-color: $daterangepicker-ranges-bg-color; -$daterangepicker-ranges-active-border-radius: $daterangepicker-border-radius; - -// STYLESHEETS -.daterangepicker { - position: absolute; - color: $daterangepicker-color; - background-color: $daterangepicker-bg-color; - border-radius: $daterangepicker-border-radius; - width: $daterangepicker-width; - padding: $daterangepicker-padding; - margin-top: $daterangepicker-border-size; - - // TODO: Should these be parameterized?? - // top: 100px; - // left: 20px; - - $arrow-prefix-size: $arrow-size; - $arrow-suffix-size: ($arrow-size - $daterangepicker-border-size); - - &::before, - &::after { - position: absolute; - display: inline-block; - border-bottom-color: rgb(0 0 0 / 20%); - content: ''; - } - - &::before { - top: -$arrow-prefix-size; - border-right: $arrow-prefix-size solid transparent; - border-left: $arrow-prefix-size solid transparent; - border-bottom: $arrow-prefix-size solid $daterangepicker-border-color; - } - - &::after { - top: -$arrow-suffix-size; - border-right: $arrow-suffix-size solid transparent; - border-bottom: $arrow-suffix-size solid $daterangepicker-bg-color; - border-left: $arrow-suffix-size solid transparent; - } - - &.opensleft { - &::before { - // TODO: Make this relative to prefix size. - right: $arrow-prefix-size + 2px; - } - - &::after { - // TODO: Make this relative to suffix size. - right: $arrow-suffix-size + 4px; - } - } - - &.openscenter { - &::before { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - - &::after { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - } - - &.opensright { - &::before { - // TODO: Make this relative to prefix size. - left: $arrow-prefix-size + 2px; - } - - &::after { - // TODO: Make this relative to suffix size. - left: $arrow-suffix-size + 4px; - } - } - - &.dropup { - margin-top: -5px; - - // NOTE: Note sure why these are special-cased. - &::before { - top: initial; - bottom: -$arrow-prefix-size; - border-bottom: initial; - border-top: $arrow-prefix-size solid $daterangepicker-border-color; - } - - &::after { - top: initial; - bottom: -$arrow-suffix-size; - border-bottom: initial; - border-top: $arrow-suffix-size solid $daterangepicker-bg-color; - } - } - - &.dropdown-menu { - max-width: none; - z-index: $daterangepicker-dropdown-z-index; - } - - &.single { - .ranges, - .calendar { - float: none; - } - } - - /* Calendars */ - &.show-calendar { - .calendar { - display: block; - } - } - - .calendar { - display: none; - max-width: $daterangepicker-width - ($daterangepicker-calendar-margin * 2); - margin: $daterangepicker-calendar-margin; - - &.single { - .calendar-table { - border: none; - } - } - - th, - td { - white-space: nowrap; - text-align: center; - - // TODO: Should this actually be hard-coded? - min-width: 32px; - } - } - - .calendar-table { - border: $daterangepicker-calendar-border-size solid - $daterangepicker-calendar-border-color; - padding: $daterangepicker-calendar-margin; - border-radius: $daterangepicker-calendar-border-radius; - background-color: $daterangepicker-calendar-bg-color; - } - - table { - width: 100%; - margin: 0; - } - - td, - th { - text-align: center; - width: $daterangepicker-cell-width; - height: $daterangepicker-cell-height; - border-radius: $daterangepicker-cell-border-radius; - border: $daterangepicker-cell-border-size solid - $daterangepicker-cell-border-color; - white-space: nowrap; - cursor: pointer; - - &.available { - &:hover { - background-color: $daterangepicker-cell-hover-bg-color; - border-color: $daterangepicker-cell-hover-border-color; - color: $daterangepicker-cell-hover-color; - } - } - - &.week { - font-size: 80%; - color: #ccc; - } - } - - td { - &.off { - &, - &.in-range, - &.start-date, - &.end-date { - background-color: $daterangepicker-unselected-bg-color; - border-color: $daterangepicker-unselected-border-color; - color: $daterangepicker-unselected-color; - } - } - - // Date Range - &.in-range { - background-color: $daterangepicker-in-range-bg-color; - border-color: $daterangepicker-in-range-border-color; - color: $daterangepicker-in-range-color; - - // TODO: Should this be static or should it be parameterized? - border-radius: 0; - } - - &.start-date { - border-radius: $daterangepicker-cell-border-radius 0 0 - $daterangepicker-cell-border-radius; - } - - &.end-date { - border-radius: 0 $daterangepicker-cell-border-radius - $daterangepicker-cell-border-radius 0; - } - - &.start-date.end-date { - border-radius: $daterangepicker-cell-border-radius; - } - - &.active { - &, - &:hover { - background-color: $daterangepicker-active-bg-color; - border-color: $daterangepicker-active-border-color; - color: $daterangepicker-active-color; - } - } - } - - th { - &.month { - width: auto; - } - } - - // Disabled Controls - td, - option { - &.disabled { - color: #999; - cursor: not-allowed; - text-decoration: line-through; - } - } - - select { - &.monthselect, - &.yearselect { - font-size: 12px; - padding: 1px; - height: auto; - margin: 0; - cursor: default; - } - - &.monthselect { - margin-right: 2%; - width: 56%; - } - - &.yearselect { - width: 40%; - } - - &.hourselect, - &.minuteselect, - &.secondselect, - &.ampmselect { - width: 50px; - margin-bottom: 0; - } - } - - // Text Input Controls (above calendar) - .input-mini { - border: $daterangepicker-control-border-size solid - $daterangepicker-control-border-color; - border-radius: $daterangepicker-control-border-radius; - color: $daterangepicker-control-color; - height: $daterangepicker-control-line-height; - line-height: $daterangepicker-control-height; - display: block; - vertical-align: middle; - - // TODO: Should these all be static, too?? - margin: 0 0 5px; - padding: 0 6px 0 28px; - width: 100%; - - &.active { - border: $daterangepicker-control-active-border-size solid - $daterangepicker-control-active-border-color; - border-radius: $daterangepicker-control-active-border-radius; - } - } - - .daterangepicker_input { - position: relative; - padding-left: 0; - - i { - position: absolute; - - // NOTE: These appear to be eyeballed to me... - left: 8px; - top: var(--spacing-04); - } - } - - &.rtl { - .input-mini { - padding-right: 28px; - padding-left: 6px; - } - - .daterangepicker_input i { - left: auto; - right: 8px; - } - } - - // Time Picker - .calendar-time { - text-align: center; - margin: 5px auto; - line-height: $daterangepicker-control-line-height; - position: relative; - padding-left: 28px; - - select { - &.disabled { - color: $daterangepicker-control-disabled-color; - cursor: not-allowed; - } - } - } -} - -// Predefined Ranges -.ranges { - font-size: 11px; - float: none; - margin: 4px; - text-align: left; - - ul { - list-style: none; - margin: 0 auto; - padding: 0; - width: 100%; - } - - li { - font-size: 13px; - background-color: $daterangepicker-ranges-bg-color; - border: $daterangepicker-ranges-border-size solid - $daterangepicker-ranges-border-color; - border-radius: $daterangepicker-ranges-border-radius; - color: $daterangepicker-ranges-color; - padding: 3px 12px; - margin-bottom: 8px; - cursor: pointer; - - &:hover { - background-color: $daterangepicker-ranges-hover-bg-color; - color: $daterangepicker-ranges-hover-color; - } - - &.active { - background-color: $daterangepicker-ranges-hover-bg-color; - border: $daterangepicker-ranges-hover-border-size solid - $daterangepicker-ranges-hover-border-color; - color: $daterangepicker-ranges-hover-color; - } - } -} - -/* Larger Screen Styling */ -@include media-breakpoint-up(sm) { - .daterangepicker { - .glyphicon { - /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ - font-family: FontAwesome; - } - - .glyphicon-chevron-left::before { - content: '\f053'; - } - - .glyphicon-chevron-right::before { - content: '\f054'; - } - - .glyphicon-calendar::before { - content: '\f073'; - } - - width: auto; - - .ranges { - ul { - width: 160px; - } - } - - &.single { - .ranges { - ul { - width: 100%; - } - } - - .calendar.left { - clear: none; - } - - &.ltr { - .ranges, - .calendar { - float: left; - } - } - - &.rtl { - .ranges, - .calendar { - float: right; - } - } - } - - &.ltr { - direction: ltr; - text-align: left; - - .calendar { - &.left { - clear: left; - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - padding-right: 12px; - } - } - - &.right { - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-right: 12px; - } - - .ranges, - .calendar { - float: left; - } - } - - &.rtl { - direction: rtl; - text-align: right; - - .calendar { - &.left { - clear: right; - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding-left: 12px; - } - } - - &.right { - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - } - - .ranges, - .calendar { - text-align: right; - float: right; - } - } - } -} - -@include media-breakpoint-up(md) { - /* force the calendar to display on one row */ - .show-calendar { - min-width: 658px; /* width of all contained elements, IE/Edge fallback */ - width: max-content; - } - - .daterangepicker { - .ranges { - width: auto; - } - - &.ltr { - .ranges { - float: left; - clear: none !important; - } - } - - &.rtl { - .ranges { - float: right; - } - } - - .calendar { - clear: none !important; - } - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss index a4bfa532e3..e2c807e928 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -91,8 +91,3 @@ color: var(--yellow-50); } } - -.admin-page summary { - // firefox does not show markers for block items - display: list-item; -} 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 1bf487eeca..83b6fbd28a 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -85,7 +85,7 @@ } &:hover { - background-color: var(--bg-light-secondary); + background-color: $bg-light-secondary; } .welcome-message-card-img { diff --git a/services/web/frontend/stylesheets/components/daterange-picker.less b/services/web/frontend/stylesheets/components/daterange-picker.less new file mode 100644 index 0000000000..43e0e3ba55 --- /dev/null +++ b/services/web/frontend/stylesheets/components/daterange-picker.less @@ -0,0 +1,656 @@ +// +// A stylesheet for use with Bootstrap 3.x +// @author: Dan Grossman http://www.dangrossman.info/ +// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. +// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php +// @website: https://www.improvely.com/ +// + +// +// VARIABLES +// + +// +// Settings + +// The class name to contain everything within. +@arrow-size: 7px; + +// +// Colors +@daterangepicker-color: @brand-primary; +@daterangepicker-bg-color: #fff; + +@daterangepicker-cell-color: @daterangepicker-color; +@daterangepicker-cell-border-color: transparent; +@daterangepicker-cell-bg-color: @daterangepicker-bg-color; + +@daterangepicker-cell-hover-color: @daterangepicker-color; +@daterangepicker-cell-hover-border-color: @daterangepicker-cell-border-color; +@daterangepicker-cell-hover-bg-color: #eee; + +@daterangepicker-in-range-color: #000; +@daterangepicker-in-range-border-color: transparent; +@daterangepicker-in-range-bg-color: #ebf4f8; + +@daterangepicker-active-color: #fff; +@daterangepicker-active-bg-color: #138a07; +@daterangepicker-active-border-color: transparent; + +@daterangepicker-unselected-color: #999; +@daterangepicker-unselected-border-color: transparent; +@daterangepicker-unselected-bg-color: #fff; + +// +// daterangepicker +@daterangepicker-width: 278px; +@daterangepicker-padding: 4px; +@daterangepicker-z-index: 3000; + +@daterangepicker-border-size: 1px; +@daterangepicker-border-color: #ccc; +@daterangepicker-border-radius: 4px; + +// +// Calendar +@daterangepicker-calendar-margin: @daterangepicker-padding; +@daterangepicker-calendar-bg-color: @daterangepicker-bg-color; + +@daterangepicker-calendar-border-size: 1px; +@daterangepicker-calendar-border-color: @daterangepicker-bg-color; +@daterangepicker-calendar-border-radius: @daterangepicker-border-radius; + +// +// Calendar Cells +@daterangepicker-cell-size: 20px; +@daterangepicker-cell-width: @daterangepicker-cell-size; +@daterangepicker-cell-height: @daterangepicker-cell-size; + +@daterangepicker-cell-border-radius: @daterangepicker-calendar-border-radius; +@daterangepicker-cell-border-size: 1px; + +// +// Dropdowns +@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1; + +// +// Controls +@daterangepicker-control-height: 30px; +@daterangepicker-control-line-height: @daterangepicker-control-height; +@daterangepicker-control-color: #555; + +@daterangepicker-control-border-size: 1px; +@daterangepicker-control-border-color: #ccc; +@daterangepicker-control-border-radius: 4px; + +@daterangepicker-control-active-border-size: 1px; +@daterangepicker-control-active-border-color: @brand-primary; +@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius; + +@daterangepicker-control-disabled-color: #ccc; + +// +// Ranges +@daterangepicker-ranges-color: @brand-primary; +@daterangepicker-ranges-bg-color: daterangepicker-ranges-color; + +@daterangepicker-ranges-border-size: 1px; +@daterangepicker-ranges-border-color: @daterangepicker-ranges-bg-color; +@daterangepicker-ranges-border-radius: @daterangepicker-border-radius; + +@daterangepicker-ranges-hover-color: #fff; +@daterangepicker-ranges-hover-bg-color: @daterangepicker-ranges-color; +@daterangepicker-ranges-hover-border-size: @daterangepicker-ranges-border-size; +@daterangepicker-ranges-hover-border-color: @daterangepicker-ranges-hover-bg-color; +@daterangepicker-ranges-hover-border-radius: @daterangepicker-border-radius; + +@daterangepicker-ranges-active-border-size: @daterangepicker-ranges-border-size; +@daterangepicker-ranges-active-border-color: @daterangepicker-ranges-bg-color; +@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius; + +// +// STYLESHEETS +// +.daterangepicker { + position: absolute; + color: @daterangepicker-color; + background-color: @daterangepicker-bg-color; + border-radius: @daterangepicker-border-radius; + width: @daterangepicker-width; + padding: @daterangepicker-padding; + margin-top: @daterangepicker-border-size; + + // TODO: Should these be parameterized?? + // top: 100px; + // left: 20px; + + @arrow-prefix-size: @arrow-size; + @arrow-suffix-size: (@arrow-size - @daterangepicker-border-size); + + &:before, + &:after { + position: absolute; + display: inline-block; + + border-bottom-color: rgba(0, 0, 0, 0.2); + content: ''; + } + + &:before { + top: -@arrow-prefix-size; + + border-right: @arrow-prefix-size solid transparent; + border-left: @arrow-prefix-size solid transparent; + border-bottom: @arrow-prefix-size solid @daterangepicker-border-color; + } + + &:after { + top: -@arrow-suffix-size; + + border-right: @arrow-suffix-size solid transparent; + border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color; + border-left: @arrow-suffix-size solid transparent; + } + + &.opensleft { + &:before { + // TODO: Make this relative to prefix size. + right: @arrow-prefix-size + 2px; + } + + &:after { + // TODO: Make this relative to suffix size. + right: @arrow-suffix-size + 4px; + } + } + + &.openscenter { + &:before { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + + &:after { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + } + + &.opensright { + &:before { + // TODO: Make this relative to prefix size. + left: @arrow-prefix-size + 2px; + } + + &:after { + // TODO: Make this relative to suffix size. + left: @arrow-suffix-size + 4px; + } + } + + &.dropup { + margin-top: -5px; + + // NOTE: Note sure why these are special-cased. + &:before { + top: initial; + bottom: -@arrow-prefix-size; + border-bottom: initial; + border-top: @arrow-prefix-size solid @daterangepicker-border-color; + } + + &:after { + top: initial; + bottom: -@arrow-suffix-size; + border-bottom: initial; + border-top: @arrow-suffix-size solid @daterangepicker-bg-color; + } + } + + &.dropdown-menu { + max-width: none; + z-index: @daterangepicker-dropdown-z-index; + } + + &.single { + .ranges, + .calendar { + float: none; + } + } + + /* Calendars */ + &.show-calendar { + .calendar { + display: block; + } + } + + .calendar { + display: none; + max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2); + margin: @daterangepicker-calendar-margin; + + &.single { + .calendar-table { + border: none; + } + } + + th, + td { + white-space: nowrap; + text-align: center; + + // TODO: Should this actually be hard-coded? + min-width: 32px; + } + } + + .calendar-table { + border: @daterangepicker-calendar-border-size solid + @daterangepicker-calendar-border-color; + padding: @daterangepicker-calendar-margin; + border-radius: @daterangepicker-calendar-border-radius; + background-color: @daterangepicker-calendar-bg-color; + } + + table { + width: 100%; + margin: 0; + } + + td, + th { + text-align: center; + width: @daterangepicker-cell-width; + height: @daterangepicker-cell-height; + border-radius: @daterangepicker-cell-border-radius; + border: @daterangepicker-cell-border-size solid + @daterangepicker-cell-border-color; + white-space: nowrap; + cursor: pointer; + + &.available { + &:hover { + background-color: @daterangepicker-cell-hover-bg-color; + border-color: @daterangepicker-cell-hover-border-color; + color: @daterangepicker-cell-hover-color; + } + } + + &.week { + font-size: 80%; + color: #ccc; + } + } + + td { + &.off { + &, + &.in-range, + &.start-date, + &.end-date { + background-color: @daterangepicker-unselected-bg-color; + border-color: @daterangepicker-unselected-border-color; + color: @daterangepicker-unselected-color; + } + } + + // + // Date Range + &.in-range { + background-color: @daterangepicker-in-range-bg-color; + border-color: @daterangepicker-in-range-border-color; + color: @daterangepicker-in-range-color; + + // TODO: Should this be static or should it be parameterized? + border-radius: 0; + } + + &.start-date { + border-radius: @daterangepicker-cell-border-radius 0 0 + @daterangepicker-cell-border-radius; + } + + &.end-date { + border-radius: 0 @daterangepicker-cell-border-radius + @daterangepicker-cell-border-radius 0; + } + + &.start-date.end-date { + border-radius: @daterangepicker-cell-border-radius; + } + + &.active { + &, + &:hover { + background-color: @daterangepicker-active-bg-color; + border-color: @daterangepicker-active-border-color; + color: @daterangepicker-active-color; + } + } + } + + th { + &.month { + width: auto; + } + } + + // + // Disabled Controls + // + td, + option { + &.disabled { + color: #999; + cursor: not-allowed; + text-decoration: line-through; + } + } + + select { + &.monthselect, + &.yearselect { + font-size: 12px; + padding: 1px; + height: auto; + margin: 0; + cursor: default; + } + + &.monthselect { + margin-right: 2%; + width: 56%; + } + + &.yearselect { + width: 40%; + } + + &.hourselect, + &.minuteselect, + &.secondselect, + &.ampmselect { + width: 50px; + margin-bottom: 0; + } + } + + // + // Text Input Controls (above calendar) + // + .input-mini { + border: @daterangepicker-control-border-size solid + @daterangepicker-control-border-color; + border-radius: @daterangepicker-control-border-radius; + color: @daterangepicker-control-color; + height: @daterangepicker-control-line-height; + line-height: @daterangepicker-control-height; + display: block; + vertical-align: middle; + + // TODO: Should these all be static, too?? + margin: 0 0 5px 0; + padding: 0 6px 0 28px; + width: 100%; + + &.active { + border: @daterangepicker-control-active-border-size solid + @daterangepicker-control-active-border-color; + border-radius: @daterangepicker-control-active-border-radius; + } + } + + .daterangepicker_input { + position: relative; + padding-left: 0; + + i { + position: absolute; + + // NOTE: These appear to be eyeballed to me... + left: 8px; + top: 10px; + } + } + &.rtl { + .input-mini { + padding-right: 28px; + padding-left: 6px; + } + .daterangepicker_input i { + left: auto; + right: 8px; + } + } + + // + // Time Picker + // + .calendar-time { + text-align: center; + margin: 5px auto; + line-height: @daterangepicker-control-line-height; + position: relative; + padding-left: 28px; + + select { + &.disabled { + color: @daterangepicker-control-disabled-color; + cursor: not-allowed; + } + } + } +} + +// +// Predefined Ranges +// + +.ranges { + font-size: 11px; + float: none; + margin: 4px; + text-align: left; + + ul { + list-style: none; + margin: 0 auto; + padding: 0; + width: 100%; + } + + li { + font-size: 13px; + background-color: @daterangepicker-ranges-bg-color; + border: @daterangepicker-ranges-border-size solid + @daterangepicker-ranges-border-color; + border-radius: @daterangepicker-ranges-border-radius; + color: @daterangepicker-ranges-color; + padding: 3px 12px; + margin-bottom: 8px; + cursor: pointer; + + &:hover { + background-color: @daterangepicker-ranges-hover-bg-color; + color: @daterangepicker-ranges-hover-color; + } + + &.active { + background-color: @daterangepicker-ranges-hover-bg-color; + border: @daterangepicker-ranges-hover-border-size solid + @daterangepicker-ranges-hover-border-color; + color: @daterangepicker-ranges-hover-color; + } + } +} + +/* Larger Screen Styling */ +@media (min-width: 564px) { + .daterangepicker { + .glyphicon { + font-family: FontAwesome; + } + .glyphicon-chevron-left:before { + content: '\f053'; + } + .glyphicon-chevron-right:before { + content: '\f054'; + } + .glyphicon-calendar:before { + content: '\f073'; + } + + width: auto; + + .ranges { + ul { + width: 160px; + } + } + + &.single { + .ranges { + ul { + width: 100%; + } + } + + .calendar.left { + clear: none; + } + + &.ltr { + .ranges, + .calendar { + float: left; + } + } + &.rtl { + .ranges, + .calendar { + float: right; + } + } + } + + &.ltr { + direction: ltr; + text-align: left; + .calendar { + &.left { + clear: left; + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + &.right { + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-right: 12px; + } + + .calendar.left .calendar-table { + padding-right: 12px; + } + + .ranges, + .calendar { + float: left; + } + } + &.rtl { + direction: rtl; + text-align: right; + .calendar { + &.left { + clear: right; + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + &.right { + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-left: 12px; + } + + .calendar.left .calendar-table { + padding-left: 12px; + } + + .ranges, + .calendar { + text-align: right; + float: right; + } + } + } +} + +@media (min-width: 730px) { + /* force the calendar to display on one row */ + &.show-calendar { + min-width: 658px; /* width of all contained elements, IE/Edge fallback */ + width: -moz-max-content; + width: -webkit-max-content; + width: max-content; + } + + .daterangepicker { + .ranges { + width: auto; + } + &.ltr { + .ranges { + float: left; + clear: none !important; + } + } + &.rtl { + .ranges { + float: right; + } + } + + .calendar { + clear: none !important; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss b/services/web/frontend/stylesheets/components/nvd3.less similarity index 78% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss rename to services/web/frontend/stylesheets/components/nvd3.less index 4983129a80..f1fea65901 100755 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss +++ b/services/web/frontend/stylesheets/components/nvd3.less @@ -1,677 +1,691 @@ -/* stylelint-disable */ - -/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ -.nvd3 .nv-axis { - pointer-events: none; - opacity: 1; -} - -.nvd3 .nv-axis path { - fill: none; - stroke: #000; - stroke-opacity: 0.75; - shape-rendering: crispedges; -} - -.nvd3 .nv-axis path.domain { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis.nv-x path.domain { - stroke-opacity: 0; -} - -.nvd3 .nv-axis line { - fill: none; - stroke: #e5e5e5; - shape-rendering: crispedges; -} - -.nvd3 .nv-axis .zero line, - /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis .nv-axisMaxMin text { - font-weight: bold; -} - -.nvd3 .x .nv-axis .nv-axisMaxMin text, -.nvd3 .x2 .nv-axis .nv-axisMaxMin text, -.nvd3 .x3 .nv-axis .nv-axisMaxMin text { - text-anchor: middle; -} - -.nvd3 .nv-axis.nv-disabled { - opacity: 0; -} - -.nvd3 .nv-bars rect { - fill-opacity: 0.75; - transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-bars rect.hover { - fill-opacity: 1; -} - -.nvd3 .nv-bars .hover rect { - fill: lightblue; -} - -.nvd3 .nv-bars text { - fill: rgb(0 0 0 / 0%); -} - -.nvd3 .nv-bars .hover text { - fill: rgb(0 0 0 / 100%); -} - -.nvd3 .nv-multibar .nv-groups rect, -.nvd3 .nv-multibarHorizontal .nv-groups rect, -.nvd3 .nv-discretebar .nv-groups rect { - stroke-opacity: 0; - transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-multibar .nv-groups rect:hover, -.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, -.nvd3 .nv-candlestickBar .nv-ticks rect:hover, -.nvd3 .nv-discretebar .nv-groups rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-discretebar .nv-groups text, -.nvd3 .nv-multibarHorizontal .nv-groups text { - font-weight: bold; - fill: rgb(0 0 0 / 100%); - stroke: rgb(0 0 0 / 0%); -} - -/* boxplot CSS */ -.nvd3 .nv-boxplot circle { - fill-opacity: 0.5; -} - -.nvd3 .nv-boxplot circle:hover { - fill-opacity: 1; -} - -.nvd3 .nv-boxplot rect:hover { - fill-opacity: 1; -} - -.nvd3 line.nv-boxplot-median { - stroke: black; -} - -.nv-boxplot-tick:hover { - stroke-width: 2.5px; -} - -/* bullet */ -.nvd3.nv-bullet { - font: 10px sans-serif; -} - -.nvd3.nv-bullet .nv-measure { - fill-opacity: 0.8; -} - -.nvd3.nv-bullet .nv-measure:hover { - fill-opacity: 1; -} - -.nvd3.nv-bullet .nv-marker { - stroke: #000; - stroke-width: 2px; -} - -.nvd3.nv-bullet .nv-markerTriangle { - stroke: #000; - fill: #fff; - stroke-width: 1.5px; -} - -.nvd3.nv-bullet .nv-markerLine { - stroke: #000; - stroke-width: 1.5px; -} - -.nvd3.nv-bullet .nv-tick line { - stroke: #666; - stroke-width: 0.5px; -} - -.nvd3.nv-bullet .nv-range.nv-s0 { - fill: #eee; -} - -.nvd3.nv-bullet .nv-range.nv-s1 { - fill: #ddd; -} - -.nvd3.nv-bullet .nv-range.nv-s2 { - fill: #ccc; -} - -.nvd3.nv-bullet .nv-title { - font-size: 14px; - font-weight: bold; -} - -.nvd3.nv-bullet .nv-subtitle { - fill: #999; -} - -.nvd3.nv-bullet .nv-range { - fill: #bababa; - fill-opacity: 0.4; -} - -.nvd3.nv-bullet .nv-range:hover { - fill-opacity: 0.7; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { - stroke: #d62728; - fill: #d62728; -} - -.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-candlestickBar .nv-ticks line { - stroke: #333; -} - -.nv-force-node { - stroke: #fff; - stroke-width: 1.5px; -} - -.nv-force-link { - stroke: #999; - stroke-opacity: 0.6; -} - -.nv-force-node text { - stroke-width: 0; -} - -.nvd3 .nv-legend .nv-disabled rect { - /* fill-opacity: 0; */ -} - -.nvd3 .nv-check-box .nv-box { - fill-opacity: 0; - stroke-width: 2; -} - -.nvd3 .nv-check-box .nv-check { - fill-opacity: 0; - stroke-width: 4; -} - -.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { - opacity: 0; -} - -/* line plus bar */ -.nvd3.nv-linePlusBar .nv-bar rect { - fill-opacity: 0.75; -} - -.nvd3.nv-linePlusBar .nv-bar rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-groups path.nv-line { - fill: none; -} - -.nvd3 .nv-groups path.nv-area { - stroke: none; -} - -.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { - fill-opacity: 0.5 !important; - stroke-opacity: 0.5 !important; -} - -.with-transitions .nvd3 .nv-groups .nv-point { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-scatter .nv-groups .nv-point.hover, -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 7px; - fill-opacity: 0.95 !important; - stroke-opacity: 0.95 !important; -} - -.nvd3 .nv-point-paths path { - stroke: #aaa; - stroke-opacity: 0; - fill: #eee; - fill-opacity: 0; -} - -.nvd3 .nv-indexLine { - cursor: ew-resize; -} - -/******************** - * SVG CSS - */ - -/******************** - Default CSS for an svg element nvd3 used -*/ -svg.nvd3-svg, svg.nvd3-iddle { - -webkit-touch-callout: none; - user-select: none; - display: block; - width: 100%; - height: 100%; -} - -/******************** - Box shadow and border radius styling -*/ -.nvtooltip.with-3d-shadow, -.with-3d-shadow .nvtooltip { - box-shadow: 0 5px 10px rgb(0 0 0 / 20%); - border-radius: 5px; -} - -.nvd3 text { - font: normal 12px Arial; -} - -.nvd3 .title { - font: bold 14px Arial; -} - -.nvd3 .nv-background { - fill: white; - fill-opacity: 0; -} - -.nvd3.nv-noData { - font-size: 18px; - font-weight: bold; -} - -/********** -* Brush -*/ - -.nv-brush .extent { - fill-opacity: 0.125; - shape-rendering: crispedges; -} - -.nv-brush .resize path { - fill: #eee; - stroke: #666; -} - -/********** -* Legend -*/ - -.nvd3 .nv-legend .nv-series { - cursor: pointer; -} - -.nvd3 .nv-legend .nv-disabled circle { - fill-opacity: 0; -} - -/* focus */ -.nvd3 .nv-brush .extent { - fill-opacity: 0 !important; -} - -.nvd3 .nv-brushBackground rect { - stroke: #000; - stroke-width: 0.4; - fill: #fff; - fill-opacity: 0.7; -} - -/********** -* Print -*/ - -@media print { - .nvd3 text { - stroke-width: 0; - fill-opacity: 1; - } -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { - stroke: #2ca02c; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { - stroke: #d62728; -} - -.nvd3 .background path { - fill: none; - stroke: #eee; - stroke-opacity: 0.4; - shape-rendering: crispedges; -} - -.nvd3 .foreground path { - fill: none; - stroke-opacity: 0.7; -} - -.nvd3 .nv-parallelCoordinates-brush .extent { - fill: #fff; - fill-opacity: 0.6; - stroke: gray; - shape-rendering: crispedges; -} - -.nvd3 .nv-parallelCoordinates .hover { - fill-opacity: 1; - stroke-width: 3px; -} - -.nvd3 .missingValuesline line { - fill: none; - stroke: black; - stroke-width: 1; - stroke-opacity: 1; - stroke-dasharray: 5, 5; -} - -.nvd3.nv-pie path { - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; -} - -.nvd3.nv-pie .nv-pie-title { - font-size: 24px; - fill: rgb(19 196 249 / 59%); -} - -.nvd3.nv-pie .nv-slice text { - stroke: #000; - stroke-width: 0; -} - -.nvd3.nv-pie path { - stroke: #fff; - stroke-width: 1px; - stroke-opacity: 1; -} - -.nvd3.nv-pie path { - fill-opacity: 0.7; -} - -.nvd3.nv-pie .hover path { - fill-opacity: 1; -} - -.nvd3.nv-pie .nv-label { - pointer-events: none; -} - -.nvd3.nv-pie .nv-label rect { - fill-opacity: 0; - stroke-opacity: 0; -} - -/* scatter */ -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 20px; - stroke-opacity: 0.5; -} - -.nvd3 .nv-scatter .nv-point.hover { - fill-opacity: 1; -} - -.nv-noninteractive { - pointer-events: none; -} - -.nv-distx, -.nv-disty { - pointer-events: none; -} - -/* sparkline */ -.nvd3.nv-sparkline path { - fill: none; -} - -.nvd3.nv-sparklineplus g.nv-hoverValue { - pointer-events: none; -} - -.nvd3.nv-sparklineplus .nv-hoverValue line { - stroke: #333; - stroke-width: 1.5px; -} - -.nvd3.nv-sparklineplus, -.nvd3.nv-sparklineplus g { - pointer-events: all; -} - -.nvd3 .nv-hoverArea { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-sparklineplus .nv-xValue, -.nvd3.nv-sparklineplus .nv-yValue { - stroke-width: 0; - font-size: 0.9em; - font-weight: normal; -} - -.nvd3.nv-sparklineplus .nv-yValue { - stroke: #f66; -} - -.nvd3.nv-sparklineplus .nv-maxValue { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-sparklineplus .nv-minValue { - stroke: #d62728; - fill: #d62728; -} - -.nvd3.nv-sparklineplus .nv-currentValue { - font-weight: bold; - font-size: 1.1em; -} - -/* stacked area */ -.nvd3.nv-stackedarea path.nv-area { - fill-opacity: 0.7; - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-stackedarea path.nv-area.hover { - fill-opacity: 0.9; -} - -.nvd3.nv-stackedarea .nv-groups .nv-point { - stroke-opacity: 0; - fill-opacity: 0; -} - -.nvtooltip { - position: absolute; - background-color: rgb(255 255 255 / 100%); - color: rgb(0 0 0 / 100%); - padding: 1px; - border: 1px solid rgb(0 0 0 / 20%); - z-index: 10000; - display: block; - font-family: Arial; - font-size: 13px; - text-align: left; - pointer-events: none; - white-space: nowrap; - -webkit-touch-callout: none; - user-select: none; -} - -.nvtooltip { - background: rgb(255 255 255 / 80%); - border: 1px solid rgb(0 0 0 / 50%); - border-radius: 4px; -} - -/* Give tooltips that old fade in transition by - putting a "with-transitions" class on the container div. -*/ -.nvtooltip.with-transitions, -.with-transitions .nvtooltip { - transition: opacity 50ms linear; - transition-delay: 200ms; -} - -.nvtooltip.x-nvtooltip, -.nvtooltip.y-nvtooltip { - padding: 8px; -} - -.nvtooltip h3 { - margin: 0; - padding: 4px 14px; - line-height: 18px; - font-weight: normal; - background-color: rgb(247 247 247 / 75%); - color: rgb(0 0 0 / 100%); - text-align: center; - border-bottom: 1px solid #ebebeb; - border-radius: 5px 5px 0 0; -} - -.nvtooltip p { - margin: 0; - padding: 5px 14px; - text-align: center; -} - -.nvtooltip span { - display: inline-block; - margin: 2px 0; -} - -.nvtooltip table { - margin: 6px; - border-spacing: 0; -} - -.nvtooltip table td { - padding: 2px 9px 2px 0; - vertical-align: middle; -} - -.nvtooltip table td.key { - font-weight: normal; -} - -.nvtooltip table td.key.total { - font-weight: bold; -} - -.nvtooltip table td.value { - text-align: right; - font-weight: bold; -} - -.nvtooltip table td.percent { - color: darkgray; -} - -.nvtooltip table tr.highlight td { - padding: 1px 9px 1px 0; - border-bottom-style: solid; - border-bottom-width: 1px; - border-top-style: solid; - border-top-width: 1px; -} - -.nvtooltip table td.legend-color-guide div { - width: 8px; - height: 8px; - vertical-align: middle; -} - -.nvtooltip table td.legend-color-guide div { - width: 12px; - height: 12px; - border: 1px solid #999; -} - -.nvtooltip .footer { - padding: 3px; - text-align: center; -} - -.nvtooltip-pending-removal { - pointer-events: none; - display: none; -} - -/**** -Interactive Layer -*/ -.nvd3 .nv-interactiveGuideLine { - pointer-events: none; -} - -.nvd3 line.nv-guideline { - stroke: #ccc; -} +/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ +.nvd3 .nv-axis { + pointer-events: none; + opacity: 1; +} + +.nvd3 .nv-axis path { + fill: none; + stroke: #000; + stroke-opacity: 0.75; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis path.domain { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis.nv-x path.domain { + stroke-opacity: 0; +} + +.nvd3 .nv-axis line { + fill: none; + stroke: #e5e5e5; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis .zero line, + /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis .nv-axisMaxMin text { + font-weight: bold; +} + +.nvd3 .x .nv-axis .nv-axisMaxMin text, +.nvd3 .x2 .nv-axis .nv-axisMaxMin text, +.nvd3 .x3 .nv-axis .nv-axisMaxMin text { + text-anchor: middle; +} + +.nvd3 .nv-axis.nv-disabled { + opacity: 0; +} + +.nvd3 .nv-bars rect { + fill-opacity: 0.75; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-bars rect.hover { + fill-opacity: 1; +} + +.nvd3 .nv-bars .hover rect { + fill: lightblue; +} + +.nvd3 .nv-bars text { + fill: rgba(0, 0, 0, 0); +} + +.nvd3 .nv-bars .hover text { + fill: rgba(0, 0, 0, 1); +} + +.nvd3 .nv-multibar .nv-groups rect, +.nvd3 .nv-multibarHorizontal .nv-groups rect, +.nvd3 .nv-discretebar .nv-groups rect { + stroke-opacity: 0; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-multibar .nv-groups rect:hover, +.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, +.nvd3 .nv-candlestickBar .nv-ticks rect:hover, +.nvd3 .nv-discretebar .nv-groups rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-discretebar .nv-groups text, +.nvd3 .nv-multibarHorizontal .nv-groups text { + font-weight: bold; + fill: rgba(0, 0, 0, 1); + stroke: rgba(0, 0, 0, 0); +} + +/* boxplot CSS */ +.nvd3 .nv-boxplot circle { + fill-opacity: 0.5; +} + +.nvd3 .nv-boxplot circle:hover { + fill-opacity: 1; +} + +.nvd3 .nv-boxplot rect:hover { + fill-opacity: 1; +} + +.nvd3 line.nv-boxplot-median { + stroke: black; +} + +.nv-boxplot-tick:hover { + stroke-width: 2.5px; +} +/* bullet */ +.nvd3.nv-bullet { + font: 10px sans-serif; +} +.nvd3.nv-bullet .nv-measure { + fill-opacity: 0.8; +} +.nvd3.nv-bullet .nv-measure:hover { + fill-opacity: 1; +} +.nvd3.nv-bullet .nv-marker { + stroke: #000; + stroke-width: 2px; +} +.nvd3.nv-bullet .nv-markerTriangle { + stroke: #000; + fill: #fff; + stroke-width: 1.5px; +} +.nvd3.nv-bullet .nv-markerLine { + stroke: #000; + stroke-width: 1.5px; +} +.nvd3.nv-bullet .nv-tick line { + stroke: #666; + stroke-width: 0.5px; +} +.nvd3.nv-bullet .nv-range.nv-s0 { + fill: #eee; +} +.nvd3.nv-bullet .nv-range.nv-s1 { + fill: #ddd; +} +.nvd3.nv-bullet .nv-range.nv-s2 { + fill: #ccc; +} +.nvd3.nv-bullet .nv-title { + font-size: 14px; + font-weight: bold; +} +.nvd3.nv-bullet .nv-subtitle { + fill: #999; +} + +.nvd3.nv-bullet .nv-range { + fill: #bababa; + fill-opacity: 0.4; +} +.nvd3.nv-bullet .nv-range:hover { + fill-opacity: 0.7; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { + stroke: #d62728; + fill: #d62728; +} + +.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-candlestickBar .nv-ticks line { + stroke: #333; +} + +.nv-force-node { + stroke: #fff; + stroke-width: 1.5px; +} +.nv-force-link { + stroke: #999; + stroke-opacity: 0.6; +} +.nv-force-node text { + stroke-width: 0px; +} + +.nvd3 .nv-legend .nv-disabled rect { + /*fill-opacity: 0;*/ +} + +.nvd3 .nv-check-box .nv-box { + fill-opacity: 0; + stroke-width: 2; +} + +.nvd3 .nv-check-box .nv-check { + fill-opacity: 0; + stroke-width: 4; +} + +.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { + opacity: 0; +} + +/* line plus bar */ +.nvd3.nv-linePlusBar .nv-bar rect { + fill-opacity: 0.75; +} + +.nvd3.nv-linePlusBar .nv-bar rect:hover { + fill-opacity: 1; +} +.nvd3 .nv-groups path.nv-line { + fill: none; +} + +.nvd3 .nv-groups path.nv-area { + stroke: none; +} + +.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { + fill-opacity: 0.5 !important; + stroke-opacity: 0.5 !important; +} + +.with-transitions .nvd3 .nv-groups .nv-point { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-scatter .nv-groups .nv-point.hover, +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 7px; + fill-opacity: 0.95 !important; + stroke-opacity: 0.95 !important; +} + +.nvd3 .nv-point-paths path { + stroke: #aaa; + stroke-opacity: 0; + fill: #eee; + fill-opacity: 0; +} + +.nvd3 .nv-indexLine { + cursor: ew-resize; +} + +/******************** + * SVG CSS + */ + +/******************** + Default CSS for an svg element nvd3 used +*/ +svg.nvd3-svg { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + user-select: none; + display: block; + width: 100%; + height: 100%; +} + +/******************** + Box shadow and border radius styling +*/ +.nvtooltip.with-3d-shadow, +.with-3d-shadow .nvtooltip { + -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + +.nvd3 text { + font: normal 12px Arial; +} + +.nvd3 .title { + font: bold 14px Arial; +} + +.nvd3 .nv-background { + fill: white; + fill-opacity: 0; +} + +.nvd3.nv-noData { + font-size: 18px; + font-weight: bold; +} + +/********** +* Brush +*/ + +.nv-brush .extent { + fill-opacity: 0.125; + shape-rendering: crispEdges; +} + +.nv-brush .resize path { + fill: #eee; + stroke: #666; +} + +/********** +* Legend +*/ + +.nvd3 .nv-legend .nv-series { + cursor: pointer; +} + +.nvd3 .nv-legend .nv-disabled circle { + fill-opacity: 0; +} + +/* focus */ +.nvd3 .nv-brush .extent { + fill-opacity: 0 !important; +} + +.nvd3 .nv-brushBackground rect { + stroke: #000; + stroke-width: 0.4; + fill: #fff; + fill-opacity: 0.7; +} + +/********** +* Print +*/ + +@media print { + .nvd3 text { + stroke-width: 0; + fill-opacity: 1; + } +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { + stroke: #2ca02c; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { + stroke: #d62728; +} + +.nvd3 .background path { + fill: none; + stroke: #eee; + stroke-opacity: 0.4; + shape-rendering: crispEdges; +} + +.nvd3 .foreground path { + fill: none; + stroke-opacity: 0.7; +} + +.nvd3 .nv-parallelCoordinates-brush .extent { + fill: #fff; + fill-opacity: 0.6; + stroke: gray; + shape-rendering: crispEdges; +} + +.nvd3 .nv-parallelCoordinates .hover { + fill-opacity: 1; + stroke-width: 3px; +} + +.nvd3 .missingValuesline line { + fill: none; + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + stroke-dasharray: 5, 5; +} +.nvd3.nv-pie path { + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; +} + +.nvd3.nv-pie .nv-pie-title { + font-size: 24px; + fill: rgba(19, 196, 249, 0.59); +} + +.nvd3.nv-pie .nv-slice text { + stroke: #000; + stroke-width: 0; +} + +.nvd3.nv-pie path { + stroke: #fff; + stroke-width: 1px; + stroke-opacity: 1; +} + +.nvd3.nv-pie path { + fill-opacity: 0.7; +} +.nvd3.nv-pie .hover path { + fill-opacity: 1; +} +.nvd3.nv-pie .nv-label { + pointer-events: none; +} +.nvd3.nv-pie .nv-label rect { + fill-opacity: 0; + stroke-opacity: 0; +} + +/* scatter */ +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 20px; + stroke-opacity: 0.5; +} + +.nvd3 .nv-scatter .nv-point.hover { + fill-opacity: 1; +} +.nv-noninteractive { + pointer-events: none; +} + +.nv-distx, +.nv-disty { + pointer-events: none; +} + +/* sparkline */ +.nvd3.nv-sparkline path { + fill: none; +} + +.nvd3.nv-sparklineplus g.nv-hoverValue { + pointer-events: none; +} + +.nvd3.nv-sparklineplus .nv-hoverValue line { + stroke: #333; + stroke-width: 1.5px; +} + +.nvd3.nv-sparklineplus, +.nvd3.nv-sparklineplus g { + pointer-events: all; +} + +.nvd3 .nv-hoverArea { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-sparklineplus .nv-xValue, +.nvd3.nv-sparklineplus .nv-yValue { + stroke-width: 0; + font-size: 0.9em; + font-weight: normal; +} + +.nvd3.nv-sparklineplus .nv-yValue { + stroke: #f66; +} + +.nvd3.nv-sparklineplus .nv-maxValue { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-sparklineplus .nv-minValue { + stroke: #d62728; + fill: #d62728; +} + +.nvd3.nv-sparklineplus .nv-currentValue { + font-weight: bold; + font-size: 1.1em; +} +/* stacked area */ +.nvd3.nv-stackedarea path.nv-area { + fill-opacity: 0.7; + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-stackedarea path.nv-area.hover { + fill-opacity: 0.9; +} + +.nvd3.nv-stackedarea .nv-groups .nv-point { + stroke-opacity: 0; + fill-opacity: 0; +} + +.nvtooltip { + position: absolute; + background-color: rgba(255, 255, 255, 1); + color: rgba(0, 0, 0, 1); + padding: 1px; + border: 1px solid rgba(0, 0, 0, 0.2); + z-index: 10000; + display: block; + + font-family: Arial; + font-size: 13px; + text-align: left; + pointer-events: none; + + white-space: nowrap; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.nvtooltip { + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(0, 0, 0, 0.5); + border-radius: 4px; +} + +/*Give tooltips that old fade in transition by + putting a "with-transitions" class on the container div. +*/ +.nvtooltip.with-transitions, +.with-transitions .nvtooltip { + transition: opacity 50ms linear; + -moz-transition: opacity 50ms linear; + -webkit-transition: opacity 50ms linear; + + transition-delay: 200ms; + -moz-transition-delay: 200ms; + -webkit-transition-delay: 200ms; +} + +.nvtooltip.x-nvtooltip, +.nvtooltip.y-nvtooltip { + padding: 8px; +} + +.nvtooltip h3 { + margin: 0; + padding: 4px 14px; + line-height: 18px; + font-weight: normal; + background-color: rgba(247, 247, 247, 0.75); + color: rgba(0, 0, 0, 1); + text-align: center; + + border-bottom: 1px solid #ebebeb; + + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.nvtooltip p { + margin: 0; + padding: 5px 14px; + text-align: center; +} + +.nvtooltip span { + display: inline-block; + margin: 2px 0; +} + +.nvtooltip table { + margin: 6px; + border-spacing: 0; +} + +.nvtooltip table td { + padding: 2px 9px 2px 0; + vertical-align: middle; +} + +.nvtooltip table td.key { + font-weight: normal; +} +.nvtooltip table td.key.total { + font-weight: bold; +} +.nvtooltip table td.value { + text-align: right; + font-weight: bold; +} + +.nvtooltip table td.percent { + color: darkgray; +} + +.nvtooltip table tr.highlight td { + padding: 1px 9px 1px 0; + border-bottom-style: solid; + border-bottom-width: 1px; + border-top-style: solid; + border-top-width: 1px; +} + +.nvtooltip table td.legend-color-guide div { + width: 8px; + height: 8px; + vertical-align: middle; +} + +.nvtooltip table td.legend-color-guide div { + width: 12px; + height: 12px; + border: 1px solid #999; +} + +.nvtooltip .footer { + padding: 3px; + text-align: center; +} + +.nvtooltip-pending-removal { + pointer-events: none; + display: none; +} + +/**** +Interactive Layer +*/ +.nvd3 .nv-interactiveGuideLine { + pointer-events: none; +} +.nvd3 line.nv-guideline { + stroke: #ccc; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss b/services/web/frontend/stylesheets/components/nvd3_override.less similarity index 74% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss rename to services/web/frontend/stylesheets/components/nvd3_override.less index 72c3e2f99a..929a99e9db 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss +++ b/services/web/frontend/stylesheets/components/nvd3_override.less @@ -5,9 +5,12 @@ opacity: 0; } } - path.domain { opacity: 0; } } } + +svg.nvd3-iddle { + &:extend(svg.nvd3-svg); +} diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index fd8c308117..d42a2ab502 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -61,6 +61,8 @@ @import 'components/hover.less'; @import 'components/ui-select.less'; @import 'components/input-suggestions.less'; +@import 'components/nvd3.less'; +@import 'components/nvd3_override.less'; @import 'components/infinite-scroll.less'; @import 'components/expand-collapse.less'; @import 'components/beta-badges.less'; @@ -80,6 +82,7 @@ @import 'components/modals.less'; @import 'components/tooltip.less'; @import 'components/popovers.less'; +@import 'components/daterange-picker'; @import 'components/lists.less'; @import 'components/overbox.less'; @import 'components/embed-responsive.less'; @@ -115,6 +118,7 @@ @import 'app/invite.less'; @import 'app/error-pages.less'; @import 'app/editor/history-v2.less'; +@import 'app/metrics.less'; @import 'app/open-in-overleaf.less'; @import 'app/primary-email-check'; @import 'app/grammarly'; @@ -122,6 +126,9 @@ @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; +@import 'app/institution-hub.less'; +@import 'app/publisher-hub.less'; +@import 'app/admin-hub.less'; @import 'app/import.less'; @import 'app/website-redesign.less'; @import 'app/add-secondary-email-prompt.less'; From 1e6112d5b0d81102bf70a94feb7f21c6224d2355 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 08:58:35 +0100 Subject: [PATCH 049/209] Merge pull request #25467 from overleaf/bg-fix-error-handling-when-accounts-are-deleted improve logging deleted when user data is expired GitOrigin-RevId: ac85b66c503184a815348a11a730fb68a504d80a --- .../web/app/src/Features/User/UserDeleter.js | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/services/web/app/src/Features/User/UserDeleter.js b/services/web/app/src/Features/User/UserDeleter.js index 662c51ca65..c8d9891bf9 100644 --- a/services/web/app/src/Features/User/UserDeleter.js +++ b/services/web/app/src/Features/User/UserDeleter.js @@ -87,17 +87,29 @@ async function deleteMongoUser(userId) { } async function expireDeletedUser(userId) { - await Modules.promises.hooks.fire('expireDeletedUser', userId) - const deletedUser = await DeletedUser.findOne({ - 'deleterData.deletedUserId': userId, - }).exec() - - await Feedback.deleteMany({ userId }).exec() - await OnboardingDataCollectionManager.deleteOnboardingDataCollection(userId) - - deletedUser.user = undefined - deletedUser.deleterData.deleterIpAddress = undefined - await deletedUser.save() + logger.info({ userId }, 'expiring deleted user') + try { + logger.info({ userId }, 'firing expireDeletedUser hook') + await Modules.promises.hooks.fire('expireDeletedUser', userId) + logger.info({ userId }, 'removing deleted user feedback records') + await Feedback.deleteMany({ userId }).exec() + logger.info({ userId }, 'removing deleted user onboarding data') + await OnboardingDataCollectionManager.deleteOnboardingDataCollection(userId) + logger.info({ userId }, 'redacting PII from the deleted user record') + const deletedUser = await DeletedUser.findOne({ + 'deleterData.deletedUserId': userId, + }).exec() + deletedUser.user = undefined + deletedUser.deleterData.deleterIpAddress = undefined + await deletedUser.save() + logger.info({ userId }, 'deleted user expiry complete') + } catch (error) { + logger.warn( + { error, userId }, + 'something went wrong expiring the deleted user' + ) + throw error + } } async function expireDeletedUsersAfterDuration() { @@ -112,11 +124,27 @@ async function expireDeletedUsersAfterDuration() { if (deletedUsers.length === 0) { return } - - for (let i = 0; i < deletedUsers.length; i++) { - const deletedUserId = deletedUsers[i].deleterData.deletedUserId - await expireDeletedUser(deletedUserId) - await UserAuditLogEntry.deleteMany({ userId: deletedUserId }).exec() + logger.info( + { deletedUsers: deletedUsers.length, retentionPeriodInDays: DURATION }, + 'expiring batch of deleted users older than retention period' + ) + try { + for (let i = 0; i < deletedUsers.length; i++) { + const deletedUserId = deletedUsers[i].deleterData.deletedUserId + await expireDeletedUser(deletedUserId) + logger.info({ deletedUserId }, 'removing deleted user audit log entries') + await UserAuditLogEntry.deleteMany({ userId: deletedUserId }).exec() + } + logger.info( + { deletedUsers: deletedUsers.length }, + 'batch of deleted users expired successfully' + ) + } catch (error) { + logger.warn( + { error }, + 'something went wrong expiring batch of deleted users' + ) + throw error } } From 842f6c289fdb18a1193545928b6ed02ef01c45b8 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 5 Jun 2025 10:07:00 +0200 Subject: [PATCH 050/209] [document-updater] make setDoc aware of tracked deletes in history-ot (#26126) GitOrigin-RevId: efa1a94f2f435058b553f639e43832454c58591d --- services/document-updater/app/js/DiffCodec.js | 54 +++- .../app/js/DocumentManager.js | 5 +- .../acceptance/js/SettingADocumentTests.js | 281 ++++++++++++++++++ 3 files changed, 330 insertions(+), 10 deletions(-) diff --git a/services/document-updater/app/js/DiffCodec.js b/services/document-updater/app/js/DiffCodec.js index 8c574cff70..17da409386 100644 --- a/services/document-updater/app/js/DiffCodec.js +++ b/services/document-updater/app/js/DiffCodec.js @@ -1,3 +1,4 @@ +const OError = require('@overleaf/o-error') const DMP = require('diff-match-patch') const { TextOperation } = require('overleaf-editor-core') const dmp = new DMP() @@ -38,23 +39,62 @@ module.exports = { return ops }, - diffAsHistoryV1EditOperation(before, after) { - const diffs = dmp.diff_main(before, after) + /** + * @param {import("overleaf-editor-core").StringFileData} file + * @param {string} after + * @return {TextOperation} + */ + diffAsHistoryOTEditOperation(file, after) { + const beforeWithoutTrackedDeletes = file.getContent({ + filterTrackedDeletes: true, + }) + const diffs = dmp.diff_main(beforeWithoutTrackedDeletes, after) dmp.diff_cleanupSemantic(diffs) + const trackedChanges = file.trackedChanges.asSorted() + let nextTc = trackedChanges.shift() + const op = new TextOperation() for (const diff of diffs) { - const [type, content] = diff + let [type, content] = diff if (type === this.ADDED) { op.insert(content) - } else if (type === this.REMOVED) { - op.remove(content.length) - } else if (type === this.UNCHANGED) { - op.retain(content.length) + } else if (type === this.REMOVED || type === this.UNCHANGED) { + while (op.baseLength + content.length > nextTc?.range.start) { + if (nextTc.tracking.type === 'delete') { + const untilRange = nextTc.range.start - op.baseLength + if (type === this.REMOVED) { + op.remove(untilRange) + } else if (type === this.UNCHANGED) { + op.retain(untilRange) + } + op.retain(nextTc.range.end - nextTc.range.start) + content = content.slice(untilRange) + } + nextTc = trackedChanges.shift() + } + if (type === this.REMOVED) { + op.remove(content.length) + } else if (type === this.UNCHANGED) { + op.retain(content.length) + } } else { throw new Error('Unknown type') } } + while (nextTc) { + if ( + nextTc.tracking.type !== 'delete' || + nextTc.range.start !== op.baseLength + ) { + throw new OError( + 'StringFileData.trackedChanges out of sync: unexpected range after end of diff', + { nextTc, baseLength: op.baseLength } + ) + } + op.retain(nextTc.range.end - nextTc.range.start) + nextTc = trackedChanges.shift() + } return op }, } diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js index 4803056423..6080c1c97d 100644 --- a/services/document-updater/app/js/DocumentManager.js +++ b/services/document-updater/app/js/DocumentManager.js @@ -194,9 +194,8 @@ const DocumentManager = { let op if (type === 'history-ot') { const file = StringFileData.fromRaw(oldLines) - const operation = DiffCodec.diffAsHistoryV1EditOperation( - // TODO(24596): tc support for history-ot - file.getContent({ filterTrackedDeletes: true }), + const operation = DiffCodec.diffAsHistoryOTEditOperation( + file, newLines.join('\n') ) if (operation.isNoop()) { diff --git a/services/document-updater/test/acceptance/js/SettingADocumentTests.js b/services/document-updater/test/acceptance/js/SettingADocumentTests.js index fd1851a221..e1bc54dc90 100644 --- a/services/document-updater/test/acceptance/js/SettingADocumentTests.js +++ b/services/document-updater/test/acceptance/js/SettingADocumentTests.js @@ -686,4 +686,285 @@ describe('Setting a document', function () { }) }) }) + + describe('with track changes (history-ot)', function () { + const lines = ['one', 'one and a half', 'two', 'three'] + const userId = DocUpdaterClient.randomId() + const ts = new Date().toISOString() + beforeEach(function (done) { + numberOfReceivedUpdates = 0 + this.newLines = ['one', 'two', 'three'] + this.project_id = DocUpdaterClient.randomId() + this.doc_id = DocUpdaterClient.randomId() + this.historyOTUpdate = { + doc: this.doc_id, + op: [ + { + textOperation: [ + 4, + { + r: 'one and a half\n'.length, + tracking: { + type: 'delete', + userId, + ts, + }, + }, + 9, + ], + }, + ], + v: this.version, + meta: { source: 'random-publicId' }, + } + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines, + version: this.version, + otMigrationStage: 1, + }) + DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => { + if (error) { + throw error + } + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error) { + throw error + } + DocUpdaterClient.waitForPendingUpdates( + this.project_id, + this.doc_id, + done + ) + } + ) + }) + }) + + afterEach(function () { + MockProjectHistoryApi.flushProject.resetHistory() + MockWebApi.setDocument.resetHistory() + }) + it('should record tracked changes', function (done) { + docUpdaterRedis.get( + Keys.docLines({ doc_id: this.doc_id }), + (error, data) => { + if (error) { + throw error + } + expect(JSON.parse(data)).to.deep.equal({ + content: lines.join('\n'), + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }) + done() + } + ) + }) + + it('should apply the change', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, data) => { + if (error) { + throw error + } + expect(data.lines).to.deep.equal(this.newLines) + done() + } + ) + }) + const cases = [ + { + name: 'when resetting the content', + lines, + want: { + content: 'one\none and a half\none and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 'one and a half\n'.length + 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when adding content before a tracked delete', + lines: ['one', 'INSERT', 'two', 'three'], + want: { + content: 'one\nINSERT\none and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 'INSERT\n'.length + 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when adding content after a tracked delete', + lines: ['one', 'two', 'INSERT', 'three'], + want: { + content: 'one\none and a half\ntwo\nINSERT\nthree', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content before a tracked delete', + lines: ['two', 'three'], + want: { + content: 'one and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 0, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content after a tracked delete', + lines: ['one', 'two'], + want: { + content: 'one\none and a half\ntwo', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content immediately after a tracked delete', + lines: ['one', 'three'], + want: { + content: 'one\none and a half\nthree', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content across a tracked delete', + lines: ['onethree'], + want: { + content: 'oneone and a half\nthree', + trackedChanges: [ + { + range: { + pos: 3, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + ] + + for (const { name, lines, want } of cases) { + describe(name, function () { + beforeEach(function (done) { + DocUpdaterClient.setDocLines( + this.project_id, + this.doc_id, + lines, + this.source, + userId, + false, + (error, res, body) => { + if (error) { + return done(error) + } + this.statusCode = res.statusCode + this.body = body + done() + } + ) + }) + it('should update accordingly', function (done) { + docUpdaterRedis.get( + Keys.docLines({ doc_id: this.doc_id }), + (error, data) => { + if (error) { + throw error + } + expect(JSON.parse(data)).to.deep.equal(want) + done() + } + ) + }) + }) + } + }) }) From af7bcfc96ae03ab6f299fa15993051127185c58f Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 09:39:37 +0100 Subject: [PATCH 051/209] Merge pull request #25486 from overleaf/bg-add-logging-when-projects-are-expired add logging when projects are expired GitOrigin-RevId: 5107f9f3d2f35aac1ee3f02a9a92c5f625d47f7a --- .../src/Features/Project/ProjectDeleter.js | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index 2bb8dd0b1f..c154637ec5 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -106,8 +106,24 @@ async function expireDeletedProjectsAfterDuration() { deletedProject => deletedProject.deleterData.deletedProjectId ) ) - for (const projectId of projectIds) { - await expireDeletedProject(projectId) + logger.info( + { projectCount: projectIds.length }, + 'expiring batch of deleted projects' + ) + try { + for (const projectId of projectIds) { + await expireDeletedProject(projectId) + } + logger.info( + { projectCount: projectIds.length }, + 'batch of deleted projects expired successfully' + ) + } catch (error) { + logger.warn( + { error }, + 'something went wrong expiring batch of deleted projects' + ) + throw error } } @@ -276,12 +292,15 @@ async function deleteProject(projectId, options = {}) { ) await Project.deleteOne({ _id: projectId }).exec() + + logger.info( + { projectId, userId: project.owner_ref }, + 'successfully deleted project' + ) } catch (err) { logger.warn({ err }, 'problem deleting project') throw err } - - logger.debug({ projectId }, 'successfully deleted project') } async function undeleteProject(projectId, options = {}) { @@ -335,16 +354,22 @@ async function undeleteProject(projectId, options = {}) { async function expireDeletedProject(projectId) { try { + logger.info({ projectId }, 'expiring deleted project') const activeProject = await Project.findById(projectId).exec() if (activeProject) { // That project is active. The deleted project record might be there // because of an incomplete delete or undelete operation. Clean it up and // return. + logger.info( + { projectId }, + 'deleted project record found but project is active' + ) await DeletedProject.deleteOne({ 'deleterData.deletedProjectId': projectId, }) return } + const deletedProject = await DeletedProject.findOne({ 'deleterData.deletedProjectId': projectId, }).exec() @@ -360,12 +385,14 @@ async function expireDeletedProject(projectId) { ) return } - + const userId = deleteProject.deletedProjectOwnerId const historyId = deletedProject.project.overleaf && deletedProject.project.overleaf.history && deletedProject.project.overleaf.history.id + logger.info({ projectId, userId }, 'destroying expired project data') + await Promise.all([ DocstoreManager.promises.destroyProject(deletedProject.project._id), HistoryManager.promises.deleteProject( @@ -378,6 +405,10 @@ async function expireDeletedProject(projectId) { Modules.promises.hooks.fire('projectExpired', deletedProject.project._id), ]) + logger.info( + { projectId, userId }, + 'redacting PII from the deleted project record' + ) await DeletedProject.updateOne( { _id: deletedProject._id, @@ -389,6 +420,7 @@ async function expireDeletedProject(projectId) { }, } ).exec() + logger.info({ projectId, userId }, 'expired deleted project successfully') } catch (error) { logger.warn({ projectId, error }, 'error expiring deleted project') throw error From d7833afd35167cee0c901deb804b5c498dea4d65 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 10:09:09 +0100 Subject: [PATCH 052/209] Merge pull request #26173 from overleaf/bg-fix-typo-in-project-deletion fix deleted project owner ID in expireDeletedProject function GitOrigin-RevId: 7e427bf9877865752f259a75b99354597d2e0a7f --- services/web/app/src/Features/Project/ProjectDeleter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index c154637ec5..b81281e319 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -385,7 +385,7 @@ async function expireDeletedProject(projectId) { ) return } - const userId = deleteProject.deletedProjectOwnerId + const userId = deletedProject.deletedProjectOwnerId const historyId = deletedProject.project.overleaf && deletedProject.project.overleaf.history && From 3b684e08caa8a686c3d5096b8a08f91079eb7747 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 5 Jun 2025 11:10:19 +0200 Subject: [PATCH 053/209] [web] fetch token users in a single db query per access mode (#26078) * [web] skip db query when getting empty list of users * [web] fetch token users in a single db query per access mode GitOrigin-RevId: fa5d9edcb761bd5d5e5ea07d137a5a86efdbdd5c --- services/web/app/src/Features/User/UserGetter.js | 1 + services/web/test/unit/src/User/UserGetterTests.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index bce4568880..a5fbe42651 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -269,6 +269,7 @@ const UserGetter = { getUsers(query, projection, callback) { try { query = normalizeMultiQuery(query) + if (query?._id?.$in?.length === 0) return callback(null, []) // shortcut for getUsers([]) db.users.find(query, { projection }).toArray(callback) } catch (err) { callback(err) diff --git a/services/web/test/unit/src/User/UserGetterTests.js b/services/web/test/unit/src/User/UserGetterTests.js index 0e0c170fd6..315a8073d6 100644 --- a/services/web/test/unit/src/User/UserGetterTests.js +++ b/services/web/test/unit/src/User/UserGetterTests.js @@ -119,6 +119,17 @@ describe('UserGetter', function () { }) }) + it('should not call mongo with empty list', function (done) { + const query = [] + const projection = { email: 1 } + this.UserGetter.getUsers(query, projection, (error, users) => { + expect(error).to.not.exist + expect(users).to.deep.equal([]) + expect(this.find).to.not.have.been.called + done() + }) + }) + it('should not allow null query', function (done) { this.UserGetter.getUser(null, {}, error => { error.should.exist From f7fcf4c23fcebb9a11f95d120007ff270ed57226 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 15:21:09 +0100 Subject: [PATCH 054/209] Remove projectHistoryMetaData from mongo db interface GitOrigin-RevId: dbbc2218c7b1ff8b7907248f86b03189e9e4006d --- services/web/app/src/infrastructure/mongodb.js | 1 - services/web/scripts/history/clean_sl_history_data.mjs | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index a3342c6575..24103b2d82 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -61,7 +61,6 @@ const db = { projectHistoryFailures: internalDb.collection('projectHistoryFailures'), projectHistoryGlobalBlobs: internalDb.collection('projectHistoryGlobalBlobs'), projectHistoryLabels: internalDb.collection('projectHistoryLabels'), - projectHistoryMetaData: internalDb.collection('projectHistoryMetaData'), projectHistorySyncState: internalDb.collection('projectHistorySyncState'), projectInvites: internalDb.collection('projectInvites'), projects: internalDb.collection('projects'), diff --git a/services/web/scripts/history/clean_sl_history_data.mjs b/services/web/scripts/history/clean_sl_history_data.mjs index 8eb541e078..8f2bc1eab0 100644 --- a/services/web/scripts/history/clean_sl_history_data.mjs +++ b/services/web/scripts/history/clean_sl_history_data.mjs @@ -1,4 +1,7 @@ -import { db } from '../../app/src/infrastructure/mongodb.js' +import { + db, + getCollectionInternal, +} from '../../app/src/infrastructure/mongodb.js' import { ensureMongoTimeout } from '../helpers/env_variable_helper.mjs' import { scriptRunner } from '../lib/ScriptRunner.mjs' // Ensure default mongo query timeout has been increased 1h @@ -47,7 +50,10 @@ async function setAllowDowngradeToFalse() { async function deleteHistoryCollections() { await gracefullyDropCollection(db.docHistory) await gracefullyDropCollection(db.docHistoryIndex) - await gracefullyDropCollection(db.projectHistoryMetaData) + const projectHistoryMetaData = await getCollectionInternal( + 'projectHistoryMetaData' + ) + await gracefullyDropCollection(projectHistoryMetaData) } async function gracefullyDropCollection(collection) { From 1386ca166969719781802c4be002af81609a0877 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 15:21:27 +0100 Subject: [PATCH 055/209] Add migration for drop projectHistoryMetaData collection GitOrigin-RevId: 1ebfc60ee9591837f37e507fb1dcb059c09a7f3b --- ..._create_projectHistoryMetaData_indexes.mjs | 19 +++++++++++-------- ...drop_projectHistoryMetaData_collection.mjs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs diff --git a/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs b/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs index ef9115a8bf..9272afc2e7 100644 --- a/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs +++ b/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs @@ -1,6 +1,7 @@ /* eslint-disable no-unused-vars */ import Helpers from './lib/helpers.mjs' +import mongodb from '../app/src/infrastructure/mongodb.js' const tags = ['saas'] @@ -13,17 +14,19 @@ const indexes = [ }, ] -const migrate = async client => { - const { db } = client - - await Helpers.addIndexesToCollection(db.projectHistoryMetaData, indexes) +const migrate = async () => { + await Helpers.addIndexesToCollection( + await mongodb.getCollectionInternal('projectHistoryMetaData'), + indexes + ) } -const rollback = async client => { - const { db } = client - +const rollback = async () => { try { - await Helpers.dropIndexesFromCollection(db.projectHistoryMetaData, indexes) + await Helpers.dropIndexesFromCollection( + await mongodb.getCollectionInternal('projectHistoryMetaData'), + indexes + ) } catch (err) { console.error('Something went wrong rolling back the migrations', err) } diff --git a/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs b/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs new file mode 100644 index 0000000000..45ec81c02d --- /dev/null +++ b/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs @@ -0,0 +1,17 @@ +import Helpers from './lib/helpers.mjs' + +const tags = ['saas'] + +const migrate = async () => { + await Helpers.dropCollection('projectHistoryMetaData') +} + +const rollback = async () => { + // Can't really do anything here +} + +export default { + tags, + migrate, + rollback, +} From 24e12bfbd4acdf8eb102463868cbb0c70fac882e Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:05:55 +0200 Subject: [PATCH 056/209] Migrate institutional account linking pages to Bootstrap 5 (#25900) GitOrigin-RevId: 75734bdbde52e90305ae759789acaf4203ec49b4 --- .../stylesheets/bootstrap-5/pages/auth.scss | 40 +++++++++++++++++++ .../bootstrap-5/pages/login-register.scss | 10 +++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss index cd23cadcee..9432892c1b 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss @@ -35,6 +35,33 @@ padding-bottom: var(--spacing-09); } +.login-register-container { + max-width: 400px; + margin: 0 auto; + padding-bottom: 125px; +} + +.login-register-error-container { + padding-bottom: var(--spacing-05); + text-align: left; +} + +.login-register-card { + padding-top: 0; + padding-bottom: 0; + text-align: center; +} + +.login-register-header { + padding-bottom: var(--spacing-07); + border-bottom: solid 1px var(--border-divider); +} + +.login-register-header-heading { + margin: 0; + color: var(--content-secondary); +} + .login-register-hr-text-container { line-height: 1; position: relative; @@ -58,6 +85,19 @@ padding: 0 var(--spacing-05); } +.login-register-text, +.login-register-hr-text-container { + margin: 0; +} + +.login-register-text { + padding-bottom: var(--spacing-08); + + &:last-child { + padding-bottom: 0; + } +} + .sso-auth-login-container { max-width: 400px; margin: 0 auto; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss index 25d3a42c4f..77771806a3 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss @@ -22,9 +22,13 @@ } } -.login-register-error-container { - padding-bottom: var(--spacing-05); - text-align: left; +.login-register-form { + padding: var(--spacing-08) var(--spacing-08) 0 var(--spacing-08); + border-bottom: solid 1px var(--border-divider); + + &:last-child { + border-bottom-width: 0; + } } .form-group-password { From ae51e57c75c06cdf225ec5ba57079c6b7811ace2 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:06:02 +0200 Subject: [PATCH 057/209] Migrate user email confirmation page to Bootstrap 5 (#26026) GitOrigin-RevId: 8e12b19fb941c0adfeaa16089bfe229e8816ad8d --- services/web/app/views/user/confirm_email.pug | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/services/web/app/views/user/confirm_email.pug b/services/web/app/views/user/confirm_email.pug index 37c04880b1..13e911f386 100644 --- a/services/web/app/views/user/confirm_email.pug +++ b/services/web/app/views/user/confirm_email.pug @@ -1,60 +1,57 @@ extends ../layout-marketing - -block vars - - bootstrap5PageStatus = 'disabled' +include ../_mixins/notification block content main.content.content-alt#main-content .container .row - .col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3 + .col-lg-8.offset-lg-2.col-xl-6.offset-xl-3 .card - .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user") - h1 #{translate("confirm_email")} - form( - method="POST" - action="/logout" - id="logoutForm" - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="redirect", value=currentUrlWithQueryParams) - form( - data-ol-async-form, - data-ol-auto-submit, - name="confirmEmailForm" - action="/user/emails/confirm", - method="POST", - id="confirmEmailForm", - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="token", value=token) + .card-body + .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user") + h1 #{translate("confirm_email")} + form( + method="POST" + action="/logout" + id="logoutForm" + ) + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden", name="redirect", value=currentUrlWithQueryParams) + form( + data-ol-async-form, + data-ol-auto-submit, + name="confirmEmailForm" + action="/user/emails/confirm", + method="POST", + id="confirmEmailForm", + ) + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden", name="token", value=token) + + div(data-ol-not-sent) + +formMessages() + div(data-ol-custom-form-message="confirm-email-wrong-user" hidden) + h1.h3 #{translate("we_cant_confirm_this_email")} + p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")} + p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})} + .actions + button.btn-primary.btn.w-100( + form="logoutForm" + ) #{translate('log_in_with_a_different_account')} - div(data-ol-not-sent) - +formMessages() - div(data-ol-custom-form-message="confirm-email-wrong-user" hidden) - h1.h3 #{translate("we_cant_confirm_this_email")} - p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")} - p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})} .actions - button.btn-primary.btn.btn-block( - form="logoutForm" - ) #{translate('log_in_with_a_different_account')} + button.btn-primary.btn.w-100( + type='submit', + data-ol-disabled-inflight + data-ol-hide-on-error-message="confirm-email-wrong-user" + ) + span(data-ol-inflight="idle") + | #{translate('confirm')} + span(hidden data-ol-inflight="pending") + span(role='status').spinner-border.spinner-border-sm.mx-2 - .actions - button.btn-primary.btn.btn-block( - type='submit', - data-ol-disabled-inflight - data-ol-hide-on-error-message="confirm-email-wrong-user" - ) - span(data-ol-inflight="idle") - | #{translate('confirm')} - span(hidden data-ol-inflight="pending") - i.fa.fa-fw.fa-spin.fa-spinner(aria-hidden="true") - |  #{translate('confirming')}… - - div(hidden data-ol-sent) - .alert.alert-success - | #{translate('thank_you_email_confirmed')} - div.text-center - a.btn.btn-primary(href="/user/settings") - | #{translate('go_to_account_settings')} + div(hidden data-ol-sent) + +notification({ariaLive: 'polite', type: 'success', className: 'mb-3', content: translate("thank_you_email_confirmed")}) + div.text-center + a.btn.btn-primary(href="/user/settings") + | #{translate('go_to_account_settings')} From 784559f1b89c765899d8b14071eed4e249da8a3e Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:13:32 +0200 Subject: [PATCH 058/209] Add video caption track if captionFile is available (#25997) GitOrigin-RevId: fefcce66fe573385dfec34cc0f8697220fe418a3 --- services/web/config/settings.defaults.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 0d3ea86314..4d55f21db8 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -893,6 +893,7 @@ module.exports = { 'figcaption', 'span', 'source', + 'track', 'video', 'del', ], @@ -943,6 +944,7 @@ module.exports = { 'style', ], tr: ['class'], + track: ['src', 'kind', 'srcLang', 'label'], video: ['alt', 'class', 'controls', 'height', 'width'], }, }, From df233f3e5ef649a2efda3d4850def62c7532ea35 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 11:58:39 +0100 Subject: [PATCH 059/209] Add commands for running just mocha tests GitOrigin-RevId: 6cd5c6aedd4fb2f222a758d6aca130f178a4acf3 --- services/web/Makefile | 5 +++++ services/web/package.json | 2 ++ 2 files changed, 7 insertions(+) diff --git a/services/web/Makefile b/services/web/Makefile index 58323058b8..6ebbc357c6 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -83,6 +83,11 @@ test_unit_app: $(DOCKER_COMPOSE) run --name unit_test_$(BUILD_DIR_NAME) --rm test_unit $(DOCKER_COMPOSE) down -v -t 0 +test_unit_mocha: export COMPOSE_PROJECT_NAME=unit_test_mocha_$(BUILD_DIR_NAME) +test_unit_mocha: + $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:mocha + $(DOCKER_COMPOSE) down -v -t 0 + test_unit_esm: export COMPOSE_PROJECT_NAME=unit_test_esm_$(BUILD_DIR_NAME) test_unit_esm: $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:esm diff --git a/services/web/package.json b/services/web/package.json index cc286b9225..826e051a9d 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -13,6 +13,8 @@ "test:unit:all": "npm run test:unit:run_dir -- test/unit/src modules/*/test/unit/src", "test:unit:all:silent": "npm run test:unit:all -- --reporter dot", "test:unit:app": "npm run test:unit:run_dir -- test/unit/src", + "test:unit:mocha": "npm run test:unit:mocha:run_dir -- test/unit/src modules/*/test/unit/src", + "test:unit:mocha:run_dir": "mocha --recursive --timeout 25000 --exit --grep=$MOCHA_GREP --require test/unit/bootstrap.js --extension=js", "test:unit:esm": "vitest run", "test:unit:esm:watch": "vitest", "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --extension js,jsx,mjs,ts,tsx --grep=$MOCHA_GREP --require test/frontend/bootstrap.js --ignore '**/*.spec.{js,jsx,ts,tsx}' --ignore '**/helpers/**/*.{js,jsx,ts,tsx}' test/frontend modules/*/test/frontend", From e5d828673e4f0cdda26c492ac6e7e978d583be6e Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:32:29 -0400 Subject: [PATCH 060/209] Merge pull request #26128 from overleaf/em-no-tracked-deletes-in-cm History OT: Remove tracked deletes from CodeMirror GitOrigin-RevId: 4e7f30cf2ed90b0c261eaa4ba51a2f54fe6e3cef --- .../editor/share-js-history-ot-type.ts | 59 +--- .../source-editor/extensions/history-ot.ts | 291 ++++++++++++++---- .../source-editor/extensions/realtime.ts | 125 +++++--- 3 files changed, 303 insertions(+), 172 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 4621fd07fb..0e70e93676 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,11 +1,7 @@ import { EditOperation, EditOperationTransformer, - InsertOp, - RemoveOp, - RetainOp, StringFileData, - TextOperation, } from 'overleaf-editor-core' import { ShareDoc } from '../../../../../types/share-doc' @@ -15,7 +11,6 @@ type Api = { getText(): string getLength(): number - _register(): void } const api: Api & ThisType = { @@ -23,64 +18,12 @@ const api: Api & ThisType = { trackChangesUserId: null, getText() { - return this.snapshot.getContent() + return this.snapshot.getContent({ filterTrackedDeletes: true }) }, getLength() { return this.snapshot.getStringLength() }, - - _register() { - this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => { - const operation = ops[0] - if (operation instanceof TextOperation) { - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) - - let outputCursor = 0 - let inputCursor = 0 - let trackedChangesInvalidated = false - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - if (op.tracking != null) { - trackedChangesInvalidated = true - } - } else if (op instanceof InsertOp) { - this.emit('insert', outputCursor, op.insertion, op.insertion.length) - outputCursor += op.insertion.length - trackedChangesInvalidated = true - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) - ) - inputCursor += op.length - trackedChangesInvalidated = true - } - } - - if (inputCursor !== str.length) { - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str - ) - } - - if (trackedChangesInvalidated) { - this.emit('tracked-changes-invalidated') - } - } - }) - }, } export const historyOTType = { diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index 58c2a42540..b10a629189 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -1,4 +1,4 @@ -import { Decoration, EditorView } from '@codemirror/view' +import { Decoration, EditorView, WidgetType } from '@codemirror/view' import { ChangeSpec, EditorState, @@ -14,69 +14,151 @@ import { TrackedChangeList, } from 'overleaf-editor-core' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { HistoryOTShareDoc } from '../../../../../types/share-doc' export const historyOT = (currentDoc: DocumentContainer) => { - const trackedChanges = currentDoc.doc?.getTrackedChanges() + const trackedChanges = + currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([]) + const positionMapper = new PositionMapper(trackedChanges) return [ trackChangesUserIdState, + shareDocState.init(() => currentDoc?.doc?._doc ?? null), commentsState, - trackedChanges != null - ? trackedChangesState.init(() => - buildTrackedChangesDecorations(trackedChanges) - ) - : trackedChangesState, + trackedChangesState.init(() => ({ + decorations: buildTrackedChangesDecorations( + trackedChanges, + positionMapper + ), + positionMapper, + })), trackedChangesFilter, - rangesTheme, + trackedChangesTheme, ] } -const rangesTheme = EditorView.theme({ - '.tracked-change-insertion': { - backgroundColor: 'rgba(0, 255, 0, 0.2)', - }, - '.tracked-change-deletion': { - backgroundColor: 'rgba(255, 0, 0, 0.2)', - }, - '.comment': { - backgroundColor: 'rgba(255, 255, 0, 0.2)', - }, -}) - -const updateTrackedChangesEffect = StateEffect.define() - -export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => { - return { - effects: updateTrackedChangesEffect.of(trackedChanges), - } -} - -const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) => - Decoration.set( - trackedChanges.asSorted().map(change => - Decoration.mark({ - class: - change.tracking.type === 'insert' - ? 'tracked-change-insertion' - : 'tracked-change-deletion', - tracking: change.tracking, - }).range(change.range.pos, change.range.end) - ), - true - ) - -const trackedChangesState = StateField.define({ +export const shareDocState = StateField.define({ create() { - return Decoration.none + return null }, update(value, transaction) { - if (transaction.docChanged) { - value = value.map(transaction.changes) - } + // this state is constant + return value + }, +}) +const trackedChangesTheme = EditorView.baseTheme({ + '.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': { + backgroundColor: 'rgba(44, 142, 48, 0.30)', + }, + '&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c': + { + backgroundColor: 'rgba(243, 177, 17, 0.30)', + }, + '&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c': + { + backgroundColor: 'rgba(194, 93, 11, 0.15)', + }, + '.ol-cm-change': { + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-highlight': { + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-focus': { + padding: 'var(--half-leading, 0) 0', + }, + '&light .ol-cm-change-d': { + borderLeft: '2px dotted #c5060b', + marginLeft: '-1px', + }, + '&dark .ol-cm-change-d': { + borderLeft: '2px dotted #c5060b', + marginLeft: '-1px', + }, + '&light .ol-cm-change-d-highlight': { + borderLeft: '3px solid #c5060b', + marginLeft: '-2px', + }, + '&dark .ol-cm-change-d-highlight': { + borderLeft: '3px solid #c5060b', + marginLeft: '-2px', + }, + '&light .ol-cm-change-d-focus': { + borderLeft: '3px solid #B83A33', + marginLeft: '-2px', + }, + '&dark .ol-cm-change-d-focus': { + borderLeft: '3px solid #B83A33', + marginLeft: '-2px', + }, +}) + +export const updateTrackedChangesEffect = + StateEffect.define() + +const buildTrackedChangesDecorations = ( + trackedChanges: TrackedChangeList, + positionMapper: PositionMapper +) => { + const decorations = [] + for (const change of trackedChanges.asSorted()) { + if (change.tracking.type === 'insert') { + decorations.push( + Decoration.mark({ + class: 'ol-cm-change ol-cm-change-i', + tracking: change.tracking, + }).range( + positionMapper.toCM6(change.range.pos), + positionMapper.toCM6(change.range.end) + ) + ) + } else { + decorations.push( + Decoration.widget({ + widget: new ChangeDeletedWidget(), + side: 1, + }).range(positionMapper.toCM6(change.range.pos)) + ) + } + } + + return Decoration.set(decorations, true) +} + +class ChangeDeletedWidget extends WidgetType { + toDOM() { + const widget = document.createElement('span') + widget.classList.add('ol-cm-change') + widget.classList.add('ol-cm-change-d') + return widget + } + + eq(old: ChangeDeletedWidget) { + return true + } +} + +export const trackedChangesState = StateField.define({ + create() { + return { + decorations: Decoration.none, + positionMapper: new PositionMapper(new TrackedChangeList([])), + } + }, + + update(value, transaction) { for (const effect of transaction.effects) { if (effect.is(updateTrackedChangesEffect)) { - value = buildTrackedChangesDecorations(effect.value) + const trackedChanges = effect.value + const positionMapper = new PositionMapper(trackedChanges) + value = { + decorations: buildTrackedChangesDecorations( + effect.value, + positionMapper + ), + positionMapper, + } } } @@ -84,7 +166,7 @@ const trackedChangesState = StateField.define({ }, provide(field) { - return EditorView.decorations.from(field) + return EditorView.decorations.from(field, value => value.decorations) }, }) @@ -165,21 +247,28 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { } const trackingUserId = tr.startState.field(trackChangesUserIdState) + const positionMapper = tr.startState.field(trackedChangesState).positionMapper const startDoc = tr.startState.doc const changes: ChangeSpec[] = [] - const opBuilder = new OperationBuilder(startDoc.length) + const effects = [] + const opBuilder = new OperationBuilder( + positionMapper.toSnapshot(startDoc.length) + ) if (trackingUserId == null) { // Not tracking changes tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { // insert if (inserted.length > 0) { - opBuilder.insert(fromA, inserted.toString()) + const pos = positionMapper.toSnapshot(fromA) + opBuilder.insert(pos, inserted.toString()) } // deletion if (toA > fromA) { - opBuilder.delete(fromA, toA - fromA) + const start = positionMapper.toSnapshot(fromA) + const end = positionMapper.toSnapshot(toA) + opBuilder.delete(start, end - start) } }) } else { @@ -188,8 +277,9 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { // insertion if (inserted.length > 0) { + const pos = positionMapper.toSnapshot(fromA) opBuilder.trackedInsert( - fromA, + pos, inserted.toString(), trackingUserId, timestamp @@ -198,23 +288,23 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { // deletion if (toA > fromA) { - const deleted = startDoc.sliceString(fromA, toA) - // re-insert the deleted text after the inserted text - changes.push({ - from: fromB + inserted.length, - insert: deleted, - }) - - opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp) + const start = positionMapper.toSnapshot(fromA) + const end = positionMapper.toSnapshot(toA) + opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp) } }) } const op = opBuilder.finish() - return [ - tr, - { changes, effects: historyOTOperationEffect.of([op]), sequential: true }, - ] + const shareDoc = tr.startState.field(shareDocState) + if (shareDoc != null) { + shareDoc.submitOp([op]) + effects.push( + updateTrackedChangesEffect.of(shareDoc.snapshot.getTrackedChanges()) + ) + } + + return [tr, { changes, effects, sequential: true }] }) /** @@ -288,3 +378,74 @@ class OperationBuilder { return this.op } } + +type OffsetTable = { pos: number; map: (pos: number) => number }[] + +class PositionMapper { + private offsets: { + toCM6: OffsetTable + toSnapshot: OffsetTable + } + + constructor(trackedChanges: TrackedChangeList) { + this.offsets = { + toCM6: [{ pos: 0, map: pos => pos }], + toSnapshot: [{ pos: 0, map: pos => pos }], + } + + // Offset of the snapshot pos relative to the CM6 pos + let offset = 0 + for (const change of trackedChanges.asSorted()) { + if (change.tracking.type === 'delete') { + const deleteLength = change.range.length + const deletePos = change.range.pos + const oldOffset = offset + const newOffset = offset + deleteLength + this.offsets.toSnapshot.push({ + pos: change.range.pos - offset + 1, + map: pos => pos + newOffset, + }) + this.offsets.toCM6.push({ + pos: change.range.pos, + map: pos => deletePos - oldOffset, + }) + this.offsets.toCM6.push({ + pos: change.range.pos + deleteLength, + map: pos => pos - newOffset, + }) + offset = newOffset + } + } + } + + toCM6(snapshotPos: number) { + return this.mapPos(snapshotPos, this.offsets.toCM6) + } + + toSnapshot(cm6Pos: number) { + return this.mapPos(cm6Pos, this.offsets.toSnapshot) + } + + mapPos(pos: number, offsets: OffsetTable) { + // Binary search for the offset at the last position before pos + let low = 0 + let high = offsets.length - 1 + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const entry = offsets[middle] + if (entry.pos < pos) { + // This entry could be the right offset, but lower entries are too low + // Because we used Math.ceil(), middle is higher than low and the + // algorithm progresses. + low = middle + } else if (entry.pos > pos) { + // This entry is too high + high = middle - 1 + } else { + // This is the right entry + return entry.map(pos) + } + } + return offsets[low].map(pos) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 1797cbc17e..e9f5710338 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -4,6 +4,7 @@ import { Annotation, ChangeSpec, Text, + StateEffect, } from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' @@ -15,11 +16,18 @@ import { } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { TrackedChangeList } from 'overleaf-editor-core' import { - updateTrackedChanges, + EditOperation, + TextOperation, + InsertOp, + RemoveOp, + RetainOp, +} from 'overleaf-editor-core' +import { + updateTrackedChangesEffect, setTrackChangesUserId, - historyOTOperationEffect, + trackedChangesState, + shareDocState, } from './history-ot' /* @@ -143,10 +151,6 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) { - this.view.dispatch(updateTrackedChanges(trackedChanges)) - } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { this.otAdapter = shareDoc.otType === 'history-ot' @@ -320,22 +324,11 @@ class HistoryOTAdapter { attachShareJs() { this.checkContent() - const onInsert = this.onShareJsInsert.bind(this) - const onDelete = this.onShareJsDelete.bind(this) - const onTrackedChangesInvalidated = - this.onShareJsTrackedChangesInvalidated.bind(this) - - this.shareDoc.on('insert', onInsert) - this.shareDoc.on('delete', onDelete) - this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated) + const onRemoteOp = this.onRemoteOp.bind(this) + this.shareDoc.on('remoteop', onRemoteOp) this.shareDoc.detach_cm6 = () => { - this.shareDoc.removeListener('insert', onInsert) - this.shareDoc.removeListener('delete', onDelete) - this.shareDoc.removeListener( - 'tracked-changes-invalidated', - onTrackedChangesInvalidated - ) + this.shareDoc.removeListener('remoteop', onRemoteOp) delete this.shareDoc.detach_cm6 this.editor.detachShareJs() } @@ -357,22 +350,6 @@ class HistoryOTAdapter { return } - let snapshotUpdated = false - for (const effect of transaction.effects) { - if (effect.is(historyOTOperationEffect)) { - this.shareDoc.submitOp(effect.value) - snapshotUpdated = true - } - } - - if (snapshotUpdated || transaction.annotation(Transaction.remote)) { - window.setTimeout(() => { - this.editor.cmUpdateTrackedChanges( - this.shareDoc.snapshot.getTrackedChanges() - ) - }, 0) - } - const origin = chooseOrigin(transaction) transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin) @@ -380,20 +357,70 @@ class HistoryOTAdapter { } } - onShareJsInsert(pos: number, text: string) { - this.editor.cmInsert(pos, text, 'remote') - this.checkContent() - } + onRemoteOp(operations: EditOperation[]) { + const positionMapper = + this.editor.view.state.field(trackedChangesState).positionMapper + const changes: ChangeSpec[] = [] + let trackedChangesUpdated = false + for (const operation of operations) { + if (operation instanceof TextOperation) { + let cursor = 0 + for (const op of operation.ops) { + if (op instanceof InsertOp) { + if (op.tracking?.type !== 'delete') { + changes.push({ + from: positionMapper.toCM6(cursor), + insert: op.insertion, + }) + } + trackedChangesUpdated = true + } else if (op instanceof RemoveOp) { + changes.push({ + from: positionMapper.toCM6(cursor), + to: positionMapper.toCM6(cursor + op.length), + }) + cursor += op.length + trackedChangesUpdated = true + } else if (op instanceof RetainOp) { + if (op.tracking != null) { + if (op.tracking.type === 'delete') { + changes.push({ + from: positionMapper.toCM6(cursor), + to: positionMapper.toCM6(cursor + op.length), + }) + } + trackedChangesUpdated = true + } + cursor += op.length + } + } + } - onShareJsDelete(pos: number, text: string) { - this.editor.cmDelete(pos, text, 'remote') - this.checkContent() - } + const view = this.editor.view + const effects: StateEffect[] = [] + const scrollEffect = view + .scrollSnapshot() + .map(view.state.changes(changes)) + if (scrollEffect != null) { + effects.push(scrollEffect) + } + if (trackedChangesUpdated) { + const shareDoc = this.editor.view.state.field(shareDocState) + if (shareDoc != null) { + const trackedChanges = shareDoc.snapshot.getTrackedChanges() + effects.push(updateTrackedChangesEffect.of(trackedChanges)) + } + } - onShareJsTrackedChangesInvalidated() { - this.editor.cmUpdateTrackedChanges( - this.shareDoc.snapshot.getTrackedChanges() - ) + view.dispatch({ + changes, + effects, + annotations: [ + Transaction.remote.of(true), + Transaction.addToHistory.of(false), + ], + }) + } } onCodeMirrorChange( From 9e9ad3c00531ee780b9ba48c3b5825970c6b085a Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Fri, 6 Jun 2025 01:09:10 +0000 Subject: [PATCH 061/209] auto update translation GitOrigin-RevId: 52a28c6823536ef916c656128dbcdff1da80635b --- services/web/locales/da.json | 1 - services/web/locales/de.json | 1 - services/web/locales/fr.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/zh-CN.json | 1 - 5 files changed, 5 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 3d8b52e547..7b9df181ac 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -432,7 +432,6 @@ "disconnected": "Forbindelsen blev afbrudt", "discount_of": "Rabat på __amount__", "discover_latex_templates_and_examples": "Opdag LaTeX skabeloner og eksempler til at hjælpe med alt fra at skrive en artikel til at bruge en specifik LaTeX pakke.", - "dismiss_error_popup": "Afvis første fejlmeddelelse", "display_deleted_user": "Vis slettede brugere", "do_not_have_acct_or_do_not_want_to_link": "Hvis du ikke har en __appName__-konto, eller hvis du ikke vil kæde den sammen med din __institutionName__-konto, klik venligst __clickText__.", "do_not_link_accounts": "Kæd ikke kontoer sammen", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 11129073df..a6d68345ba 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -312,7 +312,6 @@ "disable_stop_on_first_error": "„Anhalten beim ersten Fehler“ deaktivieren", "disconnected": "Nicht verbunden", "discount_of": "__amount__ Rabatt", - "dismiss_error_popup": "Erste Fehlermeldung schließen", "do_not_have_acct_or_do_not_want_to_link": "Wenn du kein __appName__-Konto hast oder nicht mit deinem __institutionName__-Konto verknüpfen möchtest, klicke auf „__clickText__“.", "do_not_link_accounts": "Konten nicht verknüpfen", "do_you_want_to_change_your_primary_email_address_to": "Willst Du deine primäre E-Mail-Adresse in __email__ ändern?", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index c081b84651..2e80ea2132 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -344,7 +344,6 @@ "disable_stop_on_first_error": "Désactiver “Arrêter à la première erreur”", "disconnected": "Déconnecté", "discount_of": "Remise de __amount__", - "dismiss_error_popup": "Ignorer l’alerte de première erreur", "do_not_have_acct_or_do_not_want_to_link": "Si vous n’avez pas de compte __appName__ ou si vous ne souhaitez pas le lier à votre compte __institutionName__, veuillez cliquer __clickText__.", "do_not_link_accounts": "Ne pas lier les comptes", "do_you_want_to_change_your_primary_email_address_to": "Voulez-vous définir __email__ comme votre adresse email principale ?", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index 9ed626fe36..ab9f615050 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -208,7 +208,6 @@ "dictionary": "Ordbok", "disable_stop_on_first_error": "Inaktivera \"Stopp vid första fel\"", "disconnected": "Frånkopplad", - "dismiss_error_popup": "Avfärda varning om första fel", "do_not_have_acct_or_do_not_want_to_link": "Om du inte har ett __appName__-konto, eller om du inte vill länka till ditt __institutionName__-konto, vänligen klicka på __clickText__.", "do_not_link_accounts": "Länka ej konton", "documentation": "Dokumentation", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 44e303d64d..bd3519a73c 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -518,7 +518,6 @@ "discover_latex_templates_and_examples": "探索 LaTeX 模板和示例,以帮助完成从撰写期刊文章到使用特定 LaTeX 包的所有工作。", "discover_the_fastest_way_to_search_and_cite": "探索搜索和引用的最快方法", "discover_why_over_people_worldwide_trust_overleaf": "了解为什么全世界有超过__count__万人信任 Overleaf 并把工作交给它。", - "dismiss_error_popup": "忽略第一个错误提示", "display": "显示", "display_deleted_user": "显示已删除的用户", "display_math": "显示数学公式", From a8df91e91bc6a4d655bc99323de1fbceeef9ce13 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:19:42 +0200 Subject: [PATCH 062/209] Merge pull request #26087 from overleaf/mf-change-to-stripe-uk [web] Configure to use Stripe UK account GitOrigin-RevId: 0856f6da2caae8caf9887ec2acea8e7f0972e598 --- .../src/Features/Subscription/PlansLocator.js | 49 +++++++++++-------- .../views/subscriptions/dashboard-react.pug | 2 +- .../util/handle-stripe-payment-action.ts | 5 +- services/web/frontend/js/utils/meta.ts | 2 +- .../src/Subscription/PlansLocatorTests.js | 22 ++++----- .../SubscriptionViewModelBuilderTests.js | 8 +-- services/web/types/admin/subscription.ts | 2 +- .../subscription/dashboard/subscription.ts | 2 +- services/web/types/subscription/plan.ts | 17 ++++--- 9 files changed, 60 insertions(+), 49 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index c04f0c860d..1d4fe210d5 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -27,21 +27,26 @@ function ensurePlansAreSetupCorrectly() { } const recurlyPlanCodeToStripeLookupKey = { - 'professional-annual': 'professional_annual', - professional: 'professional_monthly', - professional_free_trial_7_days: 'professional_monthly', - 'collaborator-annual': 'standard_annual', - collaborator: 'standard_monthly', - collaborator_free_trial_7_days: 'standard_monthly', - 'student-annual': 'student_annual', - student: 'student_monthly', - student_free_trial_7_days: 'student_monthly', - group_professional: 'group_professional_enterprise', - group_professional_educational: 'group_professional_educational', + collaborator: 'collaborator_may2025', + 'collaborator-annual': 'collaborator_annual_may2025', + collaborator_free_trial_7_days: 'collaborator_may2025', + + professional: 'professional_may2025', + 'professional-annual': 'professional_annual_may2025', + professional_free_trial_7_days: 'professional_may2025', + + student: 'student_may2025', + 'student-annual': 'student_annual_may2025', + student_free_trial_7_days: 'student_may2025', + + // TODO: change all group plans' lookup_keys to match the UK account after they have been added group_collaborator: 'group_standard_enterprise', group_collaborator_educational: 'group_standard_educational', - 'assistant-annual': 'error_assist_annual', - assistant: 'error_assist_monthly', + group_professional: 'group_professional_enterprise', + group_professional_educational: 'group_professional_educational', + + assistant: 'assistant_may2025', + 'assistant-annual': 'assistant_annual_may2025', } /** @@ -66,10 +71,10 @@ function mapRecurlyAddOnCodeToStripeLookupKey( // Recurly always uses 'assistant' as the code regardless of the subscription duration if (recurlyAddOnCode === 'assistant') { if (billingCycleInterval === 'month') { - return 'error_assist_monthly' + return 'assistant_may2025' } if (billingCycleInterval === 'year') { - return 'error_assist_annual' + return 'assistant_annual_may2025' } } return null @@ -77,21 +82,25 @@ function mapRecurlyAddOnCodeToStripeLookupKey( const recurlyPlanCodeToPlanTypeAndPeriod = { collaborator: { planType: 'individual', period: 'monthly' }, - collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, 'collaborator-annual': { planType: 'individual', period: 'annual' }, + collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, + professional: { planType: 'individual', period: 'monthly' }, + 'professional-annual': { planType: 'individual', period: 'annual' }, professional_free_trial_7_days: { planType: 'individual', period: 'monthly', }, - 'professional-annual': { planType: 'individual', period: 'annual' }, + student: { planType: 'student', period: 'monthly' }, - student_free_trial_7_days: { planType: 'student', period: 'monthly' }, 'student-annual': { planType: 'student', period: 'annual' }, - group_professional: { planType: 'group', period: 'annual' }, - group_professional_educational: { planType: 'group', period: 'annual' }, + student_free_trial_7_days: { planType: 'student', period: 'monthly' }, + group_collaborator: { planType: 'group', period: 'annual' }, group_collaborator_educational: { planType: 'group', period: 'annual' }, + group_professional: { planType: 'group', period: 'annual' }, + group_professional_educational: { planType: 'group', period: 'annual' }, + assistant: { planType: null, period: 'monthly' }, 'assistant-annual': { planType: null, period: 'annual' }, } diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 8cc5ec1976..2b6251f2a3 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -27,7 +27,7 @@ block append meta meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-stripeApiKey" content=settings.apis.stripe.publishableKey) + meta(name="ol-stripeUKApiKey" content=settings.apis.stripeUK.publishableKey) meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts index fd29674893..f533cba730 100644 --- a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts +++ b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts @@ -8,8 +8,9 @@ export default async function handleStripePaymentAction( const clientSecret = error?.data?.clientSecret if (clientSecret) { - const stripePublicKey = getMeta('ol-stripeApiKey') - const stripe = await loadStripe(stripePublicKey) + // TODO: support both US and UK Stripe accounts + const stripeUKPublicKey = getMeta('ol-stripeUKApiKey') + const stripe = await loadStripe(stripeUKPublicKey) if (stripe) { const manualConfirmationFlow = await stripe.confirmCardPayment(clientSecret) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 1dd4af88e0..f2692a0b7b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -240,8 +240,8 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string - 'ol-stripeApiKey': string 'ol-stripeCustomerId': string + 'ol-stripeUKApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index 0c7a6dca03..e0db2e825d 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -55,63 +55,63 @@ describe('PlansLocator', function () { const planCode = 'collaborator' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_monthly') + expect(lookupKey).to.equal('collaborator_may2025') }) it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'collaborator_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_monthly') + expect(lookupKey).to.equal('collaborator_may2025') }) it('should map "collaborator-annual" plan code to stripe lookup keys', function () { const planCode = 'collaborator-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_annual') + expect(lookupKey).to.equal('collaborator_annual_may2025') }) it('should map "professional" plan code to stripe lookup keys', function () { const planCode = 'professional' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_monthly') + expect(lookupKey).to.equal('professional_may2025') }) it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'professional_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_monthly') + expect(lookupKey).to.equal('professional_may2025') }) it('should map "professional-annual" plan code to stripe lookup keys', function () { const planCode = 'professional-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_annual') + expect(lookupKey).to.equal('professional_annual_may2025') }) it('should map "student" plan code to stripe lookup keys', function () { const planCode = 'student' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_monthly') + expect(lookupKey).to.equal('student_may2025') }) it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'student_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_monthly') + expect(lookupKey).to.equal('student_may2025') }) it('should map "student-annual" plan code to stripe lookup keys', function () { const planCode = 'student-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_annual') + expect(lookupKey).to.equal('student_annual_may2025') }) }) @@ -141,7 +141,7 @@ describe('PlansLocator', function () { addOnCode, billingCycleInterval ) - expect(lookupKey).to.equal('error_assist_monthly') + expect(lookupKey).to.equal('assistant_may2025') }) it('returns the key for an annual AI assist add-on', function () { @@ -151,7 +151,7 @@ describe('PlansLocator', function () { addOnCode, billingCycleInterval ) - expect(lookupKey).to.equal('error_assist_annual') + expect(lookupKey).to.equal('assistant_annual_may2025') }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 0f666b888a..a7c02f1e65 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -589,7 +589,7 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForGroupPlan', function () { it('is false for Stripe subscriptions', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -627,7 +627,7 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForPause', function () { it('is false for Stripe subscriptions', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -777,7 +777,7 @@ describe('SubscriptionViewModelBuilder', function () { this.paymentRecord.pausePeriodStart = null this.paymentRecord.remainingPauseCycles = null this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -847,7 +847,7 @@ describe('SubscriptionViewModelBuilder', function () { }) it('does not add a billing details link for a Stripe subscription', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' this.Modules.hooks.fire .withArgs('getPaymentFromRecord', this.individualSubscription) .yields(null, [ diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index ad05fbac40..811ebf54bf 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -7,7 +7,7 @@ import { TeamInvite } from '../team-invite' type RecurlyAdminClientPaymentProvider = Record type StripeAdminClientPaymentProvider = PaymentProvider & { - service: 'stripe' + service: 'stripe-us' | 'stripe-uk' } export type Subscription = { diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index a1ee934423..92a61e8ddb 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -103,7 +103,7 @@ export type MemberGroupSubscription = Omit & { admin_id: User } -type PaymentProviderService = 'stripe' | 'recurly' +type PaymentProviderService = 'stripe-us' | 'stripe-uk' | 'recurly' export type PaymentProvider = { service: PaymentProviderService diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 4759bb1255..5a0d40a695 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -91,15 +91,16 @@ export type RecurlyPlanCode = export type RecurlyAddOnCode = 'assistant' export type StripeLookupKey = - | 'standard_monthly' - | 'standard_annual' - | 'professional_monthly' - | 'professional_annual' - | 'student_monthly' - | 'student_annual' + | 'collaborator_may2025' + | 'collaborator_annual_may2025' + | 'professional_may2025' + | 'professional_annual_may2025' + | 'student_may2025' + | 'student_annual_may2025' + // TODO: change all group plans' lookup_keys to match the UK account after they have been added | 'group_standard_enterprise' | 'group_professional_enterprise' | 'group_standard_educational' | 'group_professional_educational' - | 'error_assist_annual' - | 'error_assist_monthly' + | 'assistant_annual_may2025' + | 'assistant_may2025' From 7a449f468620908f5fd0f1f24ac76d60c142fafe Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:20:22 +0200 Subject: [PATCH 063/209] Merge pull request #26014 from overleaf/kh-remaining-references-to-recurly-fields [web] update remaining references to `recurlyStatus` and `recurlySubscription_id` GitOrigin-RevId: f5e905eba598cfcd146803c6ccc36a2304021544 --- .../src/Features/Project/ProjectController.js | 8 +- .../Project/ProjectListController.mjs | 13 +- .../Features/Subscription/FeaturesUpdater.js | 6 +- .../Subscription/SubscriptionController.js | 4 +- .../Subscription/SubscriptionGroupHandler.js | 3 +- .../Subscription/SubscriptionHandler.js | 21 +- .../Subscription/SubscriptionHelper.js | 59 +++++ .../SubscriptionViewModelBuilder.js | 34 ++- .../Subscription/TeamInvitesController.mjs | 13 +- services/web/app/views/project/list-react.pug | 2 +- .../app/views/subscriptions/team/invite.pug | 2 +- .../current-plan-widget.tsx | 3 +- .../use-group-invitation-notification.tsx | 8 +- .../js/features/project-list/util/user.ts | 14 ++ .../components/group-invite/group-invite.tsx | 8 +- services/web/frontend/js/utils/meta.ts | 2 +- .../project-list/notifications.stories.tsx | 2 +- .../notifications/group-invitation.spec.tsx | 5 +- .../components/notifications.test.tsx | 2 +- .../group-invite/group-invite.test.tsx | 10 +- .../subscription/fixtures/subscriptions.ts | 12 -- .../src/Project/ProjectControllerTests.js | 3 - .../SubscriptionControllerTests.js | 4 +- .../Subscription/SubscriptionHandlerTests.js | 2 + .../Subscription/SubscriptionHelperTests.js | 202 ++++++++++++++++++ .../SubscriptionViewModelBuilderTests.js | 165 +++++++++----- .../TeamInvitesController.test.mjs | 8 +- .../types/project/dashboard/subscription.ts | 6 +- .../subscription/dashboard/subscription.ts | 1 - services/web/types/user.ts | 2 +- 30 files changed, 477 insertions(+), 147 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index e88cb53449..7b8f989458 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -14,6 +14,7 @@ const ProjectHelper = require('./ProjectHelper') const metrics = require('@overleaf/metrics') const { User } = require('../../models/User') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') +const { isPaidSubscription } = require('../Subscription/SubscriptionHelper') const LimitationsManager = require('../Subscription/LimitationsManager') const Settings = require('@overleaf/settings') const AuthorizationManager = require('../Authorization/AuthorizationManager') @@ -655,12 +656,11 @@ const _ProjectController = { } } - const hasNonRecurlySubscription = - subscription && !subscription.recurlySubscription_id + const hasPaidSubscription = isPaidSubscription(subscription) const hasManuallyCollectedSubscription = subscription?.collectionMethod === 'manual' const canPurchaseAddons = !( - hasNonRecurlySubscription || hasManuallyCollectedSubscription + hasPaidSubscription || hasManuallyCollectedSubscription ) const assistantDisabled = user.aiErrorAssistant?.enabled === false // the assistant has been manually disabled by the user const canUseErrorAssistant = @@ -792,7 +792,7 @@ const _ProjectController = { referal_id: user.referal_id, signUpDate: user.signUpDate, allowedFreeTrial, - hasRecurlySubscription: subscription?.recurlySubscription_id != null, + hasPaidSubscription, featureSwitches: user.featureSwitches, features: fullFeatureSet, featureUsage, diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index c62396e153..1faa2df017 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -26,6 +26,7 @@ import GeoIpLookup from '../../infrastructure/GeoIpLookup.js' import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js' import TutorialHandler from '../Tutorial/TutorialHandler.js' +import SubscriptionHelper from '../Subscription/SubscriptionHelper.js' /** * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types" @@ -388,13 +389,13 @@ async function projectListPage(req, res, next) { } } - let hasIndividualRecurlySubscription = false + let hasIndividualPaidSubscription = false try { - hasIndividualRecurlySubscription = - usersIndividualSubscription?.groupPlan === false && - usersIndividualSubscription?.recurlyStatus?.state !== 'canceled' && - usersIndividualSubscription?.recurlySubscription_id !== '' + hasIndividualPaidSubscription = + SubscriptionHelper.isIndividualActivePaidSubscription( + usersIndividualSubscription + ) } catch (error) { logger.error({ err: error }, 'Failed to get individual subscription') } @@ -437,7 +438,7 @@ async function projectListPage(req, res, next) { groupId: subscription._id, groupName: subscription.teamName, })), - hasIndividualRecurlySubscription, + hasIndividualPaidSubscription, userRestrictions: Array.from(req.userRestrictions || []), }) } diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js index a8c27f705f..16413c501c 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js @@ -3,6 +3,7 @@ const { callbackify } = require('util') const { callbackifyMultiResult } = require('@overleaf/promise-utils') const PlansLocator = require('./PlansLocator') const SubscriptionLocator = require('./SubscriptionLocator') +const SubscriptionHelper = require('./SubscriptionHelper') const UserFeaturesUpdater = require('./UserFeaturesUpdater') const FeaturesHelper = require('./FeaturesHelper') const Settings = require('@overleaf/settings') @@ -117,7 +118,10 @@ async function computeFeatures(userId) { async function _getIndividualFeatures(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) - if (subscription == null || subscription?.recurlyStatus?.state === 'paused') { + if ( + subscription == null || + SubscriptionHelper.getPaidSubscriptionState(subscription) === 'paused' + ) { return {} } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index aa0b97d497..d7de79f5a4 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -2,6 +2,7 @@ const SessionManager = require('../Authentication/SessionManager') const SubscriptionHandler = require('./SubscriptionHandler') +const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') const LimitationsManager = require('./LimitationsManager') const RecurlyWrapper = require('./RecurlyWrapper') @@ -262,7 +263,8 @@ async function pauseSubscription(req, res, next) { { pause_length: pauseCycles, plan_code: subscription?.planCode, - subscriptionId: subscription?.recurlySubscription_id, + subscriptionId: + SubscriptionHelper.getPaymentProviderSubscriptionId(subscription), } ) diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index c717b2eec6..ba862baa67 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -4,6 +4,7 @@ const OError = require('@overleaf/o-error') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionController = require('./SubscriptionController') +const SubscriptionHelper = require('./SubscriptionHelper') const { Subscription } = require('../../models/Subscription') const { User } = require('../../models/User') const RecurlyClient = require('./RecurlyClient') @@ -77,7 +78,7 @@ async function ensureFlexibleLicensingEnabled(plan) { } async function ensureSubscriptionIsActive(subscription) { - if (subscription?.recurlyStatus?.state !== 'active') { + if (SubscriptionHelper.getPaidSubscriptionState(subscription) !== 'active') { throw new InactiveError('The subscription is not active', { subscriptionId: subscription._id.toString(), }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 8aa0ee84eb..9471974b08 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -4,6 +4,7 @@ const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') const logger = require('@overleaf/logger') +const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const LimitationsManager = require('./LimitationsManager') @@ -101,8 +102,7 @@ async function updateSubscription(user, planCode) { if ( !hasSubscription || subscription == null || - (subscription.recurlySubscription_id == null && - subscription.paymentProvider?.subscriptionId == null) + SubscriptionHelper.getPaymentProviderSubscriptionId(subscription) == null ) { return } @@ -299,7 +299,10 @@ async function pauseSubscription(user, pauseCycles) { // only allow pausing on monthly plans not in a trial const { subscription } = await LimitationsManager.promises.userHasSubscription(user) - if (!subscription || !subscription.recurlyStatus) { + if ( + !subscription || + !SubscriptionHelper.getPaidSubscriptionState(subscription) + ) { throw new Error('No active subscription to pause') } @@ -310,10 +313,9 @@ async function pauseSubscription(user, pauseCycles) { ) { throw new Error('Can only pause monthly individual plans') } - if ( - subscription.recurlyStatus.trialEndsAt && - subscription.recurlyStatus.trialEndsAt > new Date() - ) { + const trialEndsAt = + SubscriptionHelper.getSubscriptionTrialEndsAt(subscription) + if (trialEndsAt && trialEndsAt > new Date()) { throw new Error('Cannot pause a subscription in a trial') } if (subscription.addOns?.length) { @@ -329,7 +331,10 @@ async function pauseSubscription(user, pauseCycles) { async function resumeSubscription(user) { const { subscription } = await LimitationsManager.promises.userHasSubscription(user) - if (!subscription || !subscription.recurlyStatus) { + if ( + !subscription || + !SubscriptionHelper.getPaidSubscriptionState(subscription) + ) { throw new Error('No active subscription to resume') } await RecurlyClient.promises.resumeSubscriptionByUuid( diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index efb8895280..b4acef4d3e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -86,7 +86,66 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency, locale) { } } +function isPaidSubscription(subscription) { + const hasRecurlySubscription = + subscription?.recurlySubscription_id && + subscription?.recurlySubscription_id !== '' + const hasStripeSubscription = + subscription?.paymentProvider?.subscriptionId && + subscription?.paymentProvider?.subscriptionId !== '' + return !!(subscription && (hasRecurlySubscription || hasStripeSubscription)) +} + +function isIndividualActivePaidSubscription(subscription) { + return ( + isPaidSubscription(subscription) && + subscription?.groupPlan === false && + subscription?.recurlyStatus?.state !== 'canceled' && + subscription?.paymentProvider?.state !== 'canceled' + ) +} + +function getPaymentProviderSubscriptionId(subscription) { + if (subscription?.recurlySubscription_id) { + return subscription.recurlySubscription_id + } + if (subscription?.paymentProvider?.subscriptionId) { + return subscription.paymentProvider.subscriptionId + } + return null +} + +function getPaidSubscriptionState(subscription) { + if (subscription?.recurlyStatus?.state) { + return subscription.recurlyStatus.state + } + if (subscription?.paymentProvider?.state) { + return subscription.paymentProvider.state + } + return null +} + +function getSubscriptionTrialStartedAt(subscription) { + if (subscription?.recurlyStatus) { + return subscription.recurlyStatus?.trialStartedAt + } + return subscription?.paymentProvider?.trialStartedAt +} + +function getSubscriptionTrialEndsAt(subscription) { + if (subscription?.recurlyStatus) { + return subscription.recurlyStatus?.trialEndsAt + } + return subscription?.paymentProvider?.trialEndsAt +} + module.exports = { shouldPlanChangeAtTermEnd, generateInitialLocalizedGroupPrice, + isPaidSubscription, + isIndividualActivePaidSubscription, + getPaymentProviderSubscriptionId, + getPaidSubscriptionState, + getSubscriptionTrialStartedAt, + getSubscriptionTrialEndsAt, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 441d9c2c9b..25b00c28a5 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -1,6 +1,5 @@ // ts-check const Settings = require('@overleaf/settings') -const RecurlyWrapper = require('./RecurlyWrapper') const PlansLocator = require('./PlansLocator') const { isStandaloneAiAddOnPlanCode, @@ -8,7 +7,6 @@ const { } = require('./PaymentProviderEntities') const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionLocator = require('./SubscriptionLocator') -const SubscriptionUpdater = require('./SubscriptionUpdater') const InstitutionsGetter = require('../Institutions/InstitutionsGetter') const InstitutionsManager = require('../Institutions/InstitutionsManager') const PublishersGetter = require('../Publishers/PublishersGetter') @@ -227,6 +225,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { // don't return subscription payment information delete personalSubscription.paymentProvider delete personalSubscription.recurly + delete personalSubscription.recurlySubscription_id const tax = paymentRecord.subscription.taxAmount || 0 // Some plans allow adding more seats than the base plan provides. @@ -374,15 +373,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { } } -/** - * @param {{_id: string}} user - * @returns {Promise} - */ -async function getBestSubscription(user) { - const { bestSubscription } = await getUsersSubscriptionDetails(user) - return bestSubscription -} - /** * @param {{_id: string}} user * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[]}>} @@ -400,15 +390,18 @@ async function getUsersSubscriptionDetails(user) { if ( individualSubscription && !individualSubscription.customAccount && - individualSubscription.recurlySubscription_id && - !individualSubscription.recurlyStatus?.state + SubscriptionHelper.getPaymentProviderSubscriptionId( + individualSubscription + ) && + !SubscriptionHelper.getPaidSubscriptionState(individualSubscription) ) { - const recurlySubscription = await RecurlyWrapper.promises.getSubscription( - individualSubscription.recurlySubscription_id, - { includeAccount: true } + const paymentResults = await Modules.promises.hooks.fire( + 'getPaymentFromRecordPromise', + individualSubscription ) - await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - recurlySubscription, + await Modules.promises.hooks.fire( + 'syncSubscription', + paymentResults[0]?.subscription, individualSubscription ) individualSubscription = @@ -540,7 +533,8 @@ function _isPlanEqualOrBetter(planA, planB) { function _getRemainingTrialDays(subscription) { const now = new Date() - const trialEndDate = subscription.recurlyStatus?.trialEndsAt + const trialEndDate = + SubscriptionHelper.getSubscriptionTrialEndsAt(subscription) return trialEndDate && trialEndDate > now ? Math.ceil( (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) @@ -605,10 +599,8 @@ module.exports = { buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel), buildPlansList, buildPlansListForSubscriptionDash, - getBestSubscription: callbackify(getBestSubscription), promises: { buildUsersSubscriptionViewModel, - getBestSubscription, getUsersSubscriptionDetails, }, } diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index b2c9840de4..cbe46d2c29 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -4,6 +4,7 @@ import OError from '@overleaf/o-error' import TeamInvitesHandler from './TeamInvitesHandler.js' import SessionManager from '../Authentication/SessionManager.js' import SubscriptionLocator from './SubscriptionLocator.js' +import SubscriptionHelper from './SubscriptionHelper.js' import ErrorController from '../Errors/ErrorController.js' import EmailHelper from '../Helpers/EmailHelper.js' import UserGetter from '../User/UserGetter.js' @@ -87,12 +88,10 @@ async function viewInvite(req, res, next) { const personalSubscription = await SubscriptionLocator.promises.getUsersSubscription(userId) - const hasIndividualRecurlySubscription = - personalSubscription && - personalSubscription.groupPlan === false && - personalSubscription.recurlyStatus?.state !== 'canceled' && - personalSubscription.recurlySubscription_id && - personalSubscription.recurlySubscription_id !== '' + const hasIndividualPaidSubscription = + SubscriptionHelper.isIndividualActivePaidSubscription( + personalSubscription + ) if (subscription?.managedUsersEnabled) { if (!subscription.populated('groupPolicy')) { @@ -155,7 +154,7 @@ async function viewInvite(req, res, next) { return res.render('subscriptions/team/invite', { inviterName: invite.inviterName, inviteToken: invite.token, - hasIndividualRecurlySubscription, + hasIndividualPaidSubscription, expired: req.query.expired, userRestrictions: Array.from(req.userRestrictions || []), currentManagedUserAdminEmail, diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index be9233ecbb..60e7d0c0fc 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -34,7 +34,7 @@ block append meta meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency) meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner) meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) - meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) + meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess) meta(name="ol-showUSGovBanner" data-type="boolean" content=showUSGovBanner) meta(name="ol-usGovBannerVariant" data-type="string" content=usGovBannerVariant) diff --git a/services/web/app/views/subscriptions/team/invite.pug b/services/web/app/views/subscriptions/team/invite.pug index dc1b509cbf..1b2ecb4646 100644 --- a/services/web/app/views/subscriptions/team/invite.pug +++ b/services/web/app/views/subscriptions/team/invite.pug @@ -4,7 +4,7 @@ block entrypointVar - entrypoint = 'pages/user/subscription/invite' block append meta - meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) + meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) meta(name="ol-inviterName" data-type="string" content=inviterName) meta(name="ol-inviteToken" data-type="string" content=inviteToken) meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx index 20bfe55479..1d17fe75a3 100644 --- a/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx @@ -4,6 +4,7 @@ import GroupPlan from './group-plan' import CommonsPlan from './commons-plan' import PausedPlan from './paused-plan' import getMeta from '../../../../utils/meta' +import { getUserSubscriptionState } from '../../util/user' function CurrentPlanWidget() { const usersBestSubscription = getMeta('ol-usersBestSubscription') @@ -19,7 +20,7 @@ function CurrentPlanWidget() { const isCommonsPlan = type === 'commons' const isPaused = isIndividualPlan && - usersBestSubscription.subscription?.recurlyStatus?.state === 'paused' + getUserSubscriptionState(usersBestSubscription) === 'paused' const featuresPageURL = '/learn/how-to/Overleaf_premium_features' const subscriptionPageUrl = '/user/subscription' diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx index 6c25513124..15248f8c42 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx @@ -57,19 +57,19 @@ export function useGroupInvitationNotification( const location = useLocation() const { handleDismiss } = useAsyncDismiss() - const hasIndividualRecurlySubscription = getMeta( - 'ol-hasIndividualRecurlySubscription' + const hasIndividualPaidSubscription = getMeta( + 'ol-hasIndividualPaidSubscription' ) useEffect(() => { - if (hasIndividualRecurlySubscription) { + if (hasIndividualPaidSubscription) { setGroupInvitationStatus( GroupInvitationStatus.CancelIndividualSubscription ) } else { setGroupInvitationStatus(GroupInvitationStatus.AskToJoin) } - }, [hasIndividualRecurlySubscription]) + }, [hasIndividualPaidSubscription]) const acceptGroupInvite = useCallback(() => { if (managedUsersEnabled) { diff --git a/services/web/frontend/js/features/project-list/util/user.ts b/services/web/frontend/js/features/project-list/util/user.ts index cb63ba3aee..115ad03cbc 100644 --- a/services/web/frontend/js/features/project-list/util/user.ts +++ b/services/web/frontend/js/features/project-list/util/user.ts @@ -1,4 +1,5 @@ import { UserRef } from '../../../../../types/project/dashboard/api' +import { Subscription } from '../../../../../types/project/dashboard/subscription' import getMeta from '@/utils/meta' export function getUserName(user: UserRef) { @@ -20,3 +21,16 @@ export function getUserName(user: UserRef) { return 'None' } + +export function getUserSubscriptionState(subscription: Subscription) { + if ('subscription' in subscription) { + if (subscription.subscription.recurlyStatus) { + return subscription.subscription.recurlyStatus.state + } + if (subscription.subscription.paymentProvider) { + return subscription.subscription.paymentProvider.state + } + } + + return null +} diff --git a/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx b/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx index a4e8fb2da8..66b6288388 100644 --- a/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx +++ b/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx @@ -19,20 +19,20 @@ export type InviteViewTypes = | undefined function GroupInviteViews() { - const hasIndividualRecurlySubscription = getMeta( - 'ol-hasIndividualRecurlySubscription' + const hasIndividualPaidSubscription = getMeta( + 'ol-hasIndividualPaidSubscription' ) const cannotJoinSubscription = getMeta('ol-cannot-join-subscription') useEffect(() => { if (cannotJoinSubscription) { setView('managed-user-cannot-join') - } else if (hasIndividualRecurlySubscription) { + } else if (hasIndividualPaidSubscription) { setView('cancel-personal-subscription') } else { setView('invite') } - }, [cannotJoinSubscription, hasIndividualRecurlySubscription]) + }, [cannotJoinSubscription, hasIndividualPaidSubscription]) const [view, setView] = useState(undefined) if (!view) { diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index f2692a0b7b..f574b1154e 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -127,7 +127,7 @@ export interface Meta { 'ol-groupsAndEnterpriseBannerVariant': GroupsAndEnterpriseBannerVariant 'ol-hasAiAssistViaWritefull': boolean 'ol-hasGroupSSOFeature': boolean - 'ol-hasIndividualRecurlySubscription': boolean + 'ol-hasIndividualPaidSubscription': boolean 'ol-hasManagedUsersFeature': boolean 'ol-hasPassword': boolean 'ol-hasSubscription': boolean diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx index 90fa82bfa5..ea00f84681 100644 --- a/services/web/frontend/stories/project-list/notifications.stories.tsx +++ b/services/web/frontend/stories/project-list/notifications.stories.tsx @@ -186,7 +186,7 @@ export const NotificationGroupInvitationCancelSubscription = (args: any) => { }, }) - window.metaAttributesCache.set('ol-hasIndividualRecurlySubscription', true) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) return ( diff --git a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx index 5767302fed..31114a2405 100644 --- a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx +++ b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx @@ -62,10 +62,7 @@ describe('', function () { describe('user with existing personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - true - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) }) it('is able to join group successfully without cancelling personal subscription', 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 78c732ebe3..9a845283d7 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 @@ -441,7 +441,7 @@ describe('', function () { ), ]) window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', + 'ol-hasIndividualPaidSubscription', true ) diff --git a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx index cc70eff90d..d7b769fd20 100644 --- a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx +++ b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx @@ -18,10 +18,7 @@ describe('group invite', function () { describe('when user has personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - true - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) }) it('renders cancel personal subscription view', async function () { @@ -55,10 +52,7 @@ describe('group invite', function () { describe('when user does not have a personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - false - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', false) window.metaAttributesCache.set('ol-inviteToken', 'token123') }) diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index 08690742d3..8011c5206d 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts @@ -25,7 +25,6 @@ export const annualActiveSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -68,7 +67,6 @@ export const annualActiveSubscriptionEuro: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -111,7 +109,6 @@ export const annualActiveSubscriptionPro: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'professional', - recurlySubscription_id: 'ghi789', plan: { planCode: 'professional', name: 'Professional', @@ -153,7 +150,6 @@ export const pastDueExpiredSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -196,7 +192,6 @@ export const canceledSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -239,7 +234,6 @@ export const pendingSubscriptionChange: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -290,7 +284,6 @@ export const groupActiveSubscription: GroupSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'group_collaborator_10_enterprise', - recurlySubscription_id: 'ghi789', plan: { planCode: 'group_collaborator_10_enterprise', name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise', @@ -338,7 +331,6 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription admin_id: 'abc123', teamInvites: [], planCode: 'group_collaborator_10_enterprise', - recurlySubscription_id: 'ghi789', plan: { planCode: 'group_collaborator_10_enterprise', name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise', @@ -396,7 +388,6 @@ export const trialSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'paid-personal_free_trial_7_days', - recurlySubscription_id: 'ghi789', plan: { planCode: 'paid-personal_free_trial_7_days', name: 'Personal', @@ -439,7 +430,6 @@ export const customSubscription: CustomSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -460,7 +450,6 @@ export const trialCollaboratorSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator_free_trial_7_days', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator_free_trial_7_days', name: 'Standard (Collaborator)', @@ -503,7 +492,6 @@ export const monthlyActiveCollaborator: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator', name: 'Standard (Collaborator)', diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 46427171da..7745ece8fa 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -201,9 +201,6 @@ describe('ProjectController', function () { getCurrentAffiliations: sinon.stub().resolves([]), }, } - this.SubscriptionViewModelBuilder = { - getBestSubscription: sinon.stub().yields(null, { type: 'free' }), - } this.SurveyHandler = { getSurvey: sinon.stub().yields(null, {}), } diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 879a31b917..087df52815 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -6,6 +6,7 @@ const MockResponse = require('../helpers/MockResponse') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionController' const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const mockSubscriptions = { 'subscription-123-active': { @@ -77,7 +78,6 @@ describe('SubscriptionController', function () { buildPlansList: sinon.stub(), promises: { buildUsersSubscriptionViewModel: sinon.stub().resolves({}), - getBestSubscription: sinon.stub().resolves({}), }, buildPlansListForSubscriptionDash: sinon .stub() @@ -146,7 +146,7 @@ describe('SubscriptionController', function () { '../SplitTests/SplitTestHandler': this.SplitTestV2Hander, '../Authentication/SessionManager': this.SessionManager, './SubscriptionHandler': this.SubscriptionHandler, - './SubscriptionHelper': this.SubscriptionHelper, + './SubscriptionHelper': SubscriptionHelper, './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder, './LimitationsManager': this.LimitationsManager, '../../infrastructure/GeoIpLookup': this.GeoIpLookup, diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index ed5ed2f6d1..7bf23defd2 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -5,6 +5,7 @@ const { expect } = chai const { PaymentProviderSubscription, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const MODULE_PATH = '../../../../app/src/Features/Subscription/SubscriptionHandler' @@ -149,6 +150,7 @@ describe('SubscriptionHandler', function () { '../../models/User': { User: this.User, }, + './SubscriptionHelper': SubscriptionHelper, './SubscriptionUpdater': this.SubscriptionUpdater, './SubscriptionLocator': this.SubscriptionLocator, './LimitationsManager': this.LimitationsManager, diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index a6e1ffa089..c700e67316 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -267,4 +267,206 @@ describe('SubscriptionHelper', function () { }) }) }) + + describe('isPaidSubscription', function () { + it('should return true for a subscription with a recurly subscription id', function () { + const result = this.SubscriptionHelper.isPaidSubscription({ + recurlySubscription_id: 'some-id', + }) + expect(result).to.be.true + }) + + it('should return true for a subscription with a stripe subscription id', function () { + const result = this.SubscriptionHelper.isPaidSubscription({ + paymentProvider: { subscriptionId: 'some-id' }, + }) + expect(result).to.be.true + }) + + it('should return false for a free subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription({}) + expect(result).to.be.false + }) + + it('should return false for a missing subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription() + expect(result).to.be.false + }) + }) + + describe('isIndividualActivePaidSubscription', function () { + it('should return true for an active recurly subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlyStatus: { state: 'active' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.true + }) + + it('should return true for an active stripe subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { subscriptionId: 'sub_123', state: 'active' }, + } + ) + expect(result).to.be.true + }) + + it('should return false for a canceled recurly subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlyStatus: { state: 'canceled' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.false + }) + + it('should return false for a canceled stripe subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { state: 'canceled', subscriptionId: 'sub_123' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a group plan subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: true, + recurlyStatus: { state: 'active' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.false + }) + + it('should return false for a free subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + {} + ) + expect(result).to.be.false + }) + + it('should return false for a subscription with an empty string for recurlySubscription_id', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlySubscription_id: '', + recurlyStatus: { state: 'active' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a subscription with an empty string for paymentProvider.subscriptionId', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { state: 'active', subscriptionId: '' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a missing subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription() + expect(result).to.be.false + }) + }) + + describe('getPaymentProviderSubscriptionId', function () { + it('should return the recurly subscription id if it exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({ + recurlySubscription_id: 'some-id', + }) + expect(result).to.equal('some-id') + }) + + it('should return the payment provider subscription id if it exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({ + paymentProvider: { subscriptionId: 'sub_123' }, + }) + expect(result).to.equal('sub_123') + }) + + it('should return null if no subscription id exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId( + {} + ) + expect(result).to.be.null + }) + }) + + describe('getPaidSubscriptionState', function () { + it('should return the recurly state if it exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({ + recurlyStatus: { state: 'active' }, + }) + expect(result).to.equal('active') + }) + + it('should return the payment provider state if it exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({ + paymentProvider: { state: 'active' }, + }) + expect(result).to.equal('active') + }) + + it('should return null if no state exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({}) + expect(result).to.be.null + }) + }) + + describe('getSubscriptionTrialStartedAt', function () { + it('should return the recurly trial start date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({ + recurlySubscription_id: 'some-id', + recurlyStatus: { trialStartedAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return the payment provider trial start date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({ + paymentProvider: { trialStartedAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return undefined if no trial start date exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({}) + expect(result).to.be.undefined + }) + }) + + describe('getSubscriptionTrialEndsAt', function () { + it('should return the recurly trial end date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({ + recurlySubscription_id: 'some-id', + recurlyStatus: { trialEndsAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return the payment provider trial end date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({ + paymentProvider: { trialEndsAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return undefined if no trial end date exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({}) + expect(result).to.be.undefined + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index a7c02f1e65..86eb51070e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -7,6 +7,7 @@ const { PaymentProviderSubscriptionAddOn, PaymentProviderSubscriptionChange, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder' @@ -159,13 +160,14 @@ describe('SubscriptionViewModelBuilder', function () { './SubscriptionUpdater': this.SubscriptionUpdater, './PlansLocator': this.PlansLocator, '../../infrastructure/Modules': (this.Modules = { + promises: { hooks: { fire: sinon.stub().resolves([]) } }, hooks: { fire: sinon.stub().yields(null, []), }, }), './V1SubscriptionManager': {}, '../Publishers/PublishersGetter': this.PublishersGetter, - './SubscriptionHelper': {}, + './SubscriptionHelper': SubscriptionHelper, }, }) @@ -180,10 +182,10 @@ describe('SubscriptionViewModelBuilder', function () { .returns(this.commonsPlan) }) - describe('getBestSubscription', function () { + describe('getUsersSubscriptionDetails', function () { it('should return a free plan when user has no subscription or affiliation', async function () { - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { type: 'free' }) @@ -195,8 +197,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualCustomSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -213,8 +215,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -234,8 +236,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -255,8 +257,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -268,8 +270,8 @@ describe('SubscriptionViewModelBuilder', function () { }) }) - it('should update subscription if recurly data is missing', async function () { - this.individualSubscriptionWithoutRecurly = { + it('should update subscription if recurly payment state is missing', async function () { + this.individualSubscriptionWithoutPaymentState = { planCode: this.planCode, plan: this.plan, recurlySubscription_id: this.recurlySubscription_id, @@ -280,37 +282,104 @@ describe('SubscriptionViewModelBuilder', function () { this.SubscriptionLocator.promises.getUsersSubscription .withArgs(this.user) .onCall(0) - .resolves(this.individualSubscriptionWithoutRecurly) + .resolves(this.individualSubscriptionWithoutPaymentState) .withArgs(this.user) .onCall(1) .resolves(this.individualSubscription) - this.RecurlyWrapper.promises.getSubscription - .withArgs(this.individualSubscription.recurlySubscription_id, { - includeAccount: true, - }) - .resolves(this.paymentRecord) + const payment = { + subscription: this.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + this.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + this.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + this.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) - sinon.assert.calledWith( - this.RecurlyWrapper.promises.getSubscription, - this.individualSubscriptionWithoutRecurly.recurlySubscription_id, - { includeAccount: true } - ) - sinon.assert.calledWith( - this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly, - this.paymentRecord, - this.individualSubscriptionWithoutRecurly - ) assert.deepEqual(usersBestSubscription, { type: 'individual', subscription: this.individualSubscription, plan: this.plan, remainingTrialDays: -1, }) + assert.isTrue( + this.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ).calledOnce + ) + }) + + it('should update subscription if stripe payment state is missing', async function () { + this.individualSubscriptionWithoutPaymentState = { + planCode: this.planCode, + plan: this.plan, + paymentProvider: { + subscriptionId: this.recurlySubscription_id, + }, + } + this.paymentRecord = { + state: 'active', + } + this.SubscriptionLocator.promises.getUsersSubscription + .withArgs(this.user) + .onCall(0) + .resolves(this.individualSubscriptionWithoutPaymentState) + .withArgs(this.user) + .onCall(1) + .resolves(this.individualSubscription) + const payment = { + subscription: this.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } + + this.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + this.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + this.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + this.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: this.individualSubscription, + plan: this.plan, + remainingTrialDays: -1, + }) + assert.isTrue( + this.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ).calledOnce + ) }) }) @@ -318,8 +387,8 @@ describe('SubscriptionViewModelBuilder', function () { this.SubscriptionLocator.promises.getMemberSubscriptions .withArgs(this.user) .resolves([this.groupSubscription]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { @@ -336,8 +405,8 @@ describe('SubscriptionViewModelBuilder', function () { .resolves([ Object.assign({}, this.groupSubscription, { teamName: 'test team' }), ]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { @@ -353,8 +422,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user._id) .resolves([this.commonsSubscription]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -385,8 +454,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 60, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -410,8 +479,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 60, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -440,8 +509,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -469,8 +538,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -499,8 +568,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index b72a406ac0..87fc435a26 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -175,7 +175,7 @@ describe('TeamInvitesController', function () { }, } - describe('hasIndividualRecurlySubscription', function () { + describe('hasIndividualPaidSubscription', function () { it('is true for personal subscription', function (ctx) { return new Promise(resolve => { ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ @@ -184,7 +184,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.true + expect(data.hasIndividualPaidSubscription).to.be.true resolve() }, } @@ -200,7 +200,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false + expect(data.hasIndividualPaidSubscription).to.be.false resolve() }, } @@ -219,7 +219,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false + expect(data.hasIndividualPaidSubscription).to.be.false resolve() }, } diff --git a/services/web/types/project/dashboard/subscription.ts b/services/web/types/project/dashboard/subscription.ts index e8b595c49f..c8f8835b34 100644 --- a/services/web/types/project/dashboard/subscription.ts +++ b/services/web/types/project/dashboard/subscription.ts @@ -1,4 +1,7 @@ -import { SubscriptionState } from '../../subscription/dashboard/subscription' +import { + SubscriptionState, + PaymentProvider, +} from '../../subscription/dashboard/subscription' type SubscriptionBase = { featuresPageURL: string @@ -22,6 +25,7 @@ type PaidSubscriptionBase = { teamName?: string name: string recurlyStatus?: RecurlyStatus + paymentProvider?: PaymentProvider } } & SubscriptionBase diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index 92a61e8ddb..db17b25684 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -64,7 +64,6 @@ export type Subscription = { membersLimit: number teamInvites: object[] planCode: string - recurlySubscription_id: string plan: Plan pendingPlan?: PendingPaymentProviderPlan addOns?: AddOn[] diff --git a/services/web/types/user.ts b/services/web/types/user.ts index 8d00ea803f..2fce1ce46b 100644 --- a/services/web/types/user.ts +++ b/services/web/types/user.ts @@ -39,7 +39,7 @@ export type User = { isAdmin?: boolean email: string allowedFreeTrial?: boolean - hasRecurlySubscription?: boolean + hasPaidSubscription?: boolean first_name?: string last_name?: string alphaProgram?: boolean From a9923fed4ed468dd8de1753d81631e58d409cda0 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:27:57 +0200 Subject: [PATCH 064/209] Merge pull request #26198 from overleaf/jpa-recurly-metrics [web] add metrics for recurly API usage GitOrigin-RevId: 89840829f86ce1ff750d57f3445f279f4b151d6f --- .../Features/Subscription/RecurlyClient.js | 21 +++++++++- .../Features/Subscription/RecurlyMetrics.js | 38 +++++++++++++++++++ .../Features/Subscription/RecurlyWrapper.js | 9 ++++- .../acceptance/src/mocks/MockRecurlyApi.mjs | 17 +++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 services/web/app/src/Features/Subscription/RecurlyMetrics.js diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 753d49ba0f..b5af796bb2 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -22,6 +22,7 @@ const { MissingBillingInfoError, SubtotalLimitExceededError, } = require('./Errors') +const RecurlyMetrics = require('./RecurlyMetrics') /** * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities' @@ -29,10 +30,28 @@ const { * @import { PaymentMethod } from './types' */ +class RecurlyClientWithErrorHandling extends recurly.Client { + /** + * @param {import('recurly/lib/recurly/Http').Response} response + * @return {Error | null} + * @private + */ + _errorFromResponse(response) { + RecurlyMetrics.recordMetrics( + response.status, + response.rateLimit, + response.rateLimitRemaining, + response.rateLimitReset.getTime() + ) + // @ts-ignore + return super._errorFromResponse(response) + } +} + const recurlySettings = Settings.apis.recurly const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined -const client = new recurly.Client(recurlyApiKey) +const client = new RecurlyClientWithErrorHandling(recurlyApiKey) /** * Get account for a given user diff --git a/services/web/app/src/Features/Subscription/RecurlyMetrics.js b/services/web/app/src/Features/Subscription/RecurlyMetrics.js new file mode 100644 index 0000000000..1b709d7dc4 --- /dev/null +++ b/services/web/app/src/Features/Subscription/RecurlyMetrics.js @@ -0,0 +1,38 @@ +const Metrics = require('@overleaf/metrics') + +/** + * @param {number} status + * @param {number} rateLimit + * @param {number} rateLimitRemaining + * @param {number} rateLimitReset + */ +function recordMetrics(status, rateLimit, rateLimitRemaining, rateLimitReset) { + Metrics.inc('recurly_request', 1, { status }) + const metrics = { rateLimit, rateLimitRemaining, rateLimitReset } + for (const [method, v] of Object.entries(metrics)) { + if (Number.isNaN(v)) continue + Metrics.gauge('recurly_request_rate_limiting', v, 1, { method }) + } +} + +/** + * @param {Response} response + */ +function recordMetricsFromResponse(response) { + const rateLimit = parseInt( + response.headers.get('X-RateLimit-Limit') || '', + 10 + ) + const rateLimitRemaining = parseInt( + response.headers.get('X-RateLimit-Remaining') || '', + 10 + ) + const rateLimitReset = + parseInt(response.headers.get('X-RateLimit-Reset') || '', 10) * 1000 + recordMetrics(response.status, rateLimit, rateLimitRemaining, rateLimitReset) +} + +module.exports = { + recordMetrics, + recordMetricsFromResponse, +} diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 234f094ae0..d5c2369009 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -9,6 +9,7 @@ const logger = require('@overleaf/logger') const Errors = require('../Errors/Errors') const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') +const RecurlyMetrics = require('./RecurlyMetrics') /** * Updates the email address of a Recurly account @@ -417,9 +418,15 @@ const promises = { } try { - return await fetchStringWithResponse(fetchUrl, fetchOptions) + const { body, response } = await fetchStringWithResponse( + fetchUrl, + fetchOptions + ) + RecurlyMetrics.recordMetricsFromResponse(response) + return { body, response } } catch (error) { if (error instanceof RequestFailedError) { + RecurlyMetrics.recordMetricsFromResponse(error.response) if (error.response.status === 404 && expect404) { return { response: error.response, body: null } } else if (error.response.status === 422 && expect422) { diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index c1e7c2aa8b..091ff62ad5 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -7,6 +7,9 @@ class MockRecurlyApi extends AbstractMockApi { this.mockSubscriptions = [] this.redemptions = {} this.coupons = {} + this.rateLimitResetSeconds = Math.ceil( + (Date.now() + 24 * 60 * 60 * 1000) / 1000 + ) } addMockSubscription(recurlySubscription) { @@ -25,7 +28,21 @@ class MockRecurlyApi extends AbstractMockApi { ) } + getRateLimitHeaders() { + return { + 'X-RateLimit-Limit': 1000, + 'X-RateLimit-Remaining': 999, + 'X-RateLimit-Reset': this.rateLimitResetSeconds, + } + } + applyRoutes() { + this.app.use((req, res, next) => { + for (const [name, v] of Object.entries(this.getRateLimitHeaders())) { + res.setHeader(name, v) + } + next() + }) this.app.get('/subscriptions/:id', (req, res) => { const subscription = this.getMockSubscriptionById(req.params.id) if (!subscription) { From d280f40885fdee1a642a334ab00a2f1933391c06 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Fri, 6 Jun 2025 11:51:02 +0100 Subject: [PATCH 065/209] Merge pull request #26116 from overleaf/bg-history-redis-show-buffer add script to display redis buffer for a given history ID GitOrigin-RevId: 71c2e79480c0873d30801ed3c13aa9a7fc7873f6 --- .../history-v1/storage/scripts/show_buffer.js | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 services/history-v1/storage/scripts/show_buffer.js diff --git a/services/history-v1/storage/scripts/show_buffer.js b/services/history-v1/storage/scripts/show_buffer.js new file mode 100644 index 0000000000..1d80ee227d --- /dev/null +++ b/services/history-v1/storage/scripts/show_buffer.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// @ts-check + +const { rclientHistory: rclient } = require('../lib/redis') +const { keySchema } = require('../lib/chunk_store/redis') +const commandLineArgs = require('command-line-args') + +const optionDefinitions = [ + { name: 'historyId', type: String, defaultOption: true }, +] + +// Column width for key display alignment; can be overridden with COL_WIDTH env variable +const COLUMN_WIDTH = process.env.COL_WIDTH + ? parseInt(process.env.COL_WIDTH, 10) + : 45 + +let options +try { + options = commandLineArgs(optionDefinitions) +} catch (e) { + console.error( + 'Error parsing command line arguments:', + e instanceof Error ? e.message : String(e) + ) + console.error('Usage: ./show_buffer.js ') + process.exit(1) +} + +const { historyId } = options + +if (!historyId) { + console.error('Usage: ./show_buffer.js ') + process.exit(1) +} + +function format(str, indent = COLUMN_WIDTH + 2) { + const lines = str.split('\n') + for (let i = 1; i < lines.length; i++) { + lines[i] = ' '.repeat(indent) + lines[i] + } + return lines.join('\n') +} + +async function displayKeyValue( + rclient, + key, + { parseJson = false, formatDate = false } = {} +) { + const value = await rclient.get(key) + let displayValue = '(nil)' + if (value) { + if (parseJson) { + try { + displayValue = format(JSON.stringify(JSON.parse(value), null, 2)) + } catch (e) { + displayValue = ` Raw value: ${value}` + } + } else if (formatDate) { + const ts = parseInt(value, 10) + displayValue = `${new Date(ts).toISOString()} (${value})` + } else { + displayValue = value + } + } + console.log(`${key.padStart(COLUMN_WIDTH)}: ${displayValue}`) +} + +async function displayBuffer(projectId) { + console.log(`Buffer for history ID: ${projectId}`) + console.log('--------------------------------------------------') + + try { + const headKey = keySchema.head({ projectId }) + const headVersionKey = keySchema.headVersion({ projectId }) + const persistedVersionKey = keySchema.persistedVersion({ projectId }) + const expireTimeKey = keySchema.expireTime({ projectId }) + const persistTimeKey = keySchema.persistTime({ projectId }) + const changesKey = keySchema.changes({ projectId }) + + await displayKeyValue(rclient, headKey, { parseJson: true }) + await displayKeyValue(rclient, headVersionKey) + await displayKeyValue(rclient, persistedVersionKey) + await displayKeyValue(rclient, expireTimeKey, { formatDate: true }) + await displayKeyValue(rclient, persistTimeKey, { formatDate: true }) + + const changesList = await rclient.lrange(changesKey, 0, -1) + + // 6. changes + let changesListDisplay = '(nil)' + if (changesList) { + changesListDisplay = changesList.length + ? format( + changesList + .map((change, index) => `[${index}]: ${change}`) + .join('\n') + ) + : '(empty list)' + } + console.log(`${changesKey.padStart(COLUMN_WIDTH)}: ${changesListDisplay}`) + } catch (error) { + console.error('Error fetching data from Redis:', error) + throw error + } +} + +;(async () => { + let errorOccurred = false + try { + await displayBuffer(historyId) + } catch (error) { + errorOccurred = true + } finally { + rclient.quit(() => { + process.exit(errorOccurred ? 1 : 0) + }) + } +})() From 2eb695f4c38bd08562572191baaae8ffae157bb2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Fri, 6 Jun 2025 11:51:16 +0100 Subject: [PATCH 066/209] Merge pull request #26122 from overleaf/bg-history-redis-make-persist-buffer-consistent make persistBuffer export consistent with other methods GitOrigin-RevId: 24536e521e1d20ef63cc74bd9ba40e095025d512 --- services/history-v1/storage/index.js | 2 +- services/history-v1/storage/lib/persist_buffer.js | 2 +- services/history-v1/storage/scripts/persist_redis_chunks.js | 2 +- .../test/acceptance/js/storage/persist_buffer.test.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index a9d8e2fc03..46fa63b689 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -8,7 +8,7 @@ exports.mongodb = require('./lib/mongodb') exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') -exports.persistBuffer = require('./lib/persist_buffer').persistBuffer +exports.persistBuffer = require('./lib/persist_buffer') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 4cfd7ecab3..1f508c43f3 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -162,4 +162,4 @@ async function persistBuffer(projectId, limits) { ) } -module.exports = { persistBuffer } +module.exports = persistBuffer diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 9d64964f81..414fbf3458 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -5,7 +5,7 @@ const knex = require('../lib/knex.js') const knexReadOnly = require('../lib/knex_read_only.js') const { client } = require('../lib/mongodb.js') const { scanAndProcessDueItems } = require('../lib/scan') -const { persistBuffer } = require('../lib/persist_buffer') +const persistBuffer = require('../lib/persist_buffer') const { claimPersistJob } = require('../lib/chunk_store/redis') const rclient = redis.rclientHistory diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 496d16cd1e..216399f676 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -10,7 +10,7 @@ import { AddFileOperation, EditFileOperation, // Added EditFileOperation } from 'overleaf-editor-core' -import { persistBuffer } from '../../../../storage/lib/persist_buffer.js' +import persistBuffer from '../../../../storage/lib/persist_buffer.js' import chunkStore from '../../../../storage/lib/chunk_store/index.js' import redisBackend from '../../../../storage/lib/chunk_store/redis.js' import persistChanges from '../../../../storage/lib/persist_changes.js' From c0b7efea102617030ff02004bfc898363d75ce0a Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 16:21:31 +0100 Subject: [PATCH 067/209] Change imports that use chai to use vitest GitOrigin-RevId: 59d780f754adbb5160a2de8e5eca1def6968584b --- .../launchpad/test/unit/src/LaunchpadController.test.mjs | 3 +-- .../src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs | 3 +-- .../test/unit/src/BetaProgram/BetaProgramController.test.mjs | 3 +-- .../web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs | 3 +-- .../unit/src/Collaborators/CollaboratorsController.test.mjs | 3 +-- .../src/Collaborators/CollaboratorsInviteController.test.mjs | 3 +-- .../src/Collaborators/CollaboratorsInviteHandler.test.mjs | 3 +-- services/web/test/unit/src/Contact/ContactController.test.mjs | 3 +-- .../web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs | 3 +-- .../src/DocumentUpdater/DocumentUpdaterController.test.mjs | 3 +-- services/web/test/unit/src/Exports/ExportsController.test.mjs | 3 +-- services/web/test/unit/src/Exports/ExportsHandler.test.mjs | 3 +-- .../web/test/unit/src/FileStore/FileStoreController.test.mjs | 3 +-- .../test/unit/src/LinkedFiles/LinkedFilesController.test.mjs | 3 +-- services/web/test/unit/src/Metadata/MetaController.test.mjs | 3 +-- services/web/test/unit/src/Metadata/MetaHandler.test.mjs | 3 +-- .../unit/src/PasswordReset/PasswordResetController.test.mjs | 3 +-- .../test/unit/src/PasswordReset/PasswordResetHandler.test.mjs | 3 +-- .../web/test/unit/src/Project/ProjectListController.test.mjs | 3 +-- services/web/test/unit/src/Referal/ReferalHandler.test.mjs | 3 +-- .../web/test/unit/src/References/ReferencesHandler.test.mjs | 4 +--- .../test/unit/src/Subscription/TeamInvitesController.test.mjs | 4 ++-- services/web/test/unit/src/Tags/TagsController.test.mjs | 4 ++-- .../test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs | 3 +-- .../unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs | 3 +-- .../test/unit/src/TokenAccess/TokenAccessController.test.mjs | 3 +-- .../test/unit/src/Uploads/ProjectUploadController.test.mjs | 3 +-- services/web/test/unit/src/User/UserPagesController.test.mjs | 3 +-- .../unit/src/UserMembership/UserMembershipController.test.mjs | 3 +-- .../test/unit/src/infrastructure/ServeStaticWrapper.test.mjs | 3 +-- 30 files changed, 32 insertions(+), 61 deletions(-) diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs index 89bc165305..e34a3583b0 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import * as path from 'node:path' -import { expect } from 'chai' import sinon from 'sinon' import MockResponse from '../../../../../test/unit/src/helpers/MockResponse.js' diff --git a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs index fff5224b48..463407b180 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs @@ -1,8 +1,7 @@ -import { vi } from 'vitest' +import { assert, vi } from 'vitest' import sinon from 'sinon' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' -import { assert } from 'chai' const MODULE_PATH = new URL( '../../../../app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware', diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs index e2160cca08..23dd4dc1c8 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs @@ -1,7 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' import { fileURLToPath } from 'node:url' diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs index 14438a8ed7..4034835666 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs @@ -1,8 +1,7 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' -import { expect } from 'chai' import { fileURLToPath } from 'node:url' const __dirname = fileURLToPath(new URL('.', import.meta.url)) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 9bb9c4b3c0..1d8345a195 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockRequest from '../helpers/MockRequest.js' diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index d948e69ed4..edac9c6c92 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import mongodb from 'mongodb-legacy' diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs index ec8f453536..5d6690d7c0 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Crypto from 'crypto' diff --git a/services/web/test/unit/src/Contact/ContactController.test.mjs b/services/web/test/unit/src/Contact/ContactController.test.mjs index 2defc2c3a7..13f70c81f6 100644 --- a/services/web/test/unit/src/Contact/ContactController.test.mjs +++ b/services/web/test/unit/src/Contact/ContactController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Contacts/ContactController.mjs' diff --git a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs index 2bb1ed81dd..846a54d4ce 100644 --- a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs +++ b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = new URL( '../../../../app/src/Features/Cooldown/CooldownMiddleware.mjs', import.meta.url diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs index 095e598d39..5a60903552 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const MODULE_PATH = diff --git a/services/web/test/unit/src/Exports/ExportsController.test.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs index af9c1483fb..cd8f4ba7a9 100644 --- a/services/web/test/unit/src/Exports/ExportsController.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = new URL( '../../../../app/src/Features/Exports/ExportsController.mjs', diff --git a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs index 0eb8a98e26..a7944beced 100644 --- a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs' describe('ExportsHandler', function () { diff --git a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs index 5c46e516a0..ba0670d49c 100644 --- a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs +++ b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index b29d10bba4..e712d17198 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs' diff --git a/services/web/test/unit/src/Metadata/MetaController.test.mjs b/services/web/test/unit/src/Metadata/MetaController.test.mjs index 00b3568ae2..ee3488137a 100644 --- a/services/web/test/unit/src/Metadata/MetaController.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs' diff --git a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs index c6009a2dd6..48d5cc51a4 100644 --- a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Metadata/MetaHandler.mjs' diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs index e4cf6e569f..05bbfdb433 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const MODULE_PATH = new URL( diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs index 25d664b795..aab46ae2bf 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = new URL( '../../../../app/src/Features/PasswordReset/PasswordResetHandler', import.meta.url diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index a051382279..2b3007e047 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' diff --git a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs index 5174918bd7..5c042f2ef9 100644 --- a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Referal/ReferalHandler.mjs' diff --git a/services/web/test/unit/src/References/ReferencesHandler.test.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs index ae7b86822a..92666e6bcc 100644 --- a/services/web/test/unit/src/References/ReferencesHandler.test.mjs +++ b/services/web/test/unit/src/References/ReferencesHandler.test.mjs @@ -1,6 +1,4 @@ -import { vi } from 'vitest' - -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' const modulePath = diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index 87fc435a26..be5fe26670 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -1,6 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' + const modulePath = '../../../../app/src/Features/Subscription/TeamInvitesController' diff --git a/services/web/test/unit/src/Tags/TagsController.test.mjs b/services/web/test/unit/src/Tags/TagsController.test.mjs index 927c6283a5..c8cb739d0e 100644 --- a/services/web/test/unit/src/Tags/TagsController.test.mjs +++ b/services/web/test/unit/src/Tags/TagsController.test.mjs @@ -1,6 +1,6 @@ -import { vi } from 'vitest' +import { assert, vi } from 'vitest' import sinon from 'sinon' -import { assert } from 'chai' + const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs' describe('TagsController', function () { diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs index 313f2d2456..29daa00efc 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import mongodb from 'mongodb-legacy' -import { expect } from 'chai' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs index 96cc22279e..08a7dcf494 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs index 3408c3bb32..96d2d19b04 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs index 1f6fd7adb9..443578f747 100644 --- a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs +++ b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs @@ -5,9 +5,8 @@ * DS206: Consider reworking classes to avoid initClass * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js' diff --git a/services/web/test/unit/src/User/UserPagesController.test.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs index 181c9513ae..1fa908d1be 100644 --- a/services/web/test/unit/src/User/UserPagesController.test.mjs +++ b/services/web/test/unit/src/User/UserPagesController.test.mjs @@ -1,7 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import assert from 'assert' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' import MockRequest from '../helpers/MockRequest.js' diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 55bc62cd2d..47932a7fe1 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js' diff --git a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs index 4d8479a9cb..619fe74a2b 100644 --- a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs +++ b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import Path from 'node:path' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' From edc7634007af7e30d9e9071a60d48a6512b6b8ff Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 16:21:51 +0100 Subject: [PATCH 068/209] Update bootstrap process to use vitest chai GitOrigin-RevId: 5576223019c0e2b4554707f0025e82ab3a7ca514 --- services/web/test/unit/bootstrap.js | 17 +++++++++++++++++ services/web/test/unit/common_bootstrap.js | 19 ------------------- services/web/test/unit/vitest_bootstrap.mjs | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/services/web/test/unit/bootstrap.js b/services/web/test/unit/bootstrap.js index f3d3f382f2..00bcc3e958 100644 --- a/services/web/test/unit/bootstrap.js +++ b/services/web/test/unit/bootstrap.js @@ -1,7 +1,24 @@ const Path = require('path') const sinon = require('sinon') require('./common_bootstrap') +const chai = require('chai') +/* + * Chai configuration + */ + +// add chai.should() +chai.should() + +// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') +// has a nicer failure messages +chai.use(require('sinon-chai')) + +// Load promise support for chai +chai.use(require('chai-as-promised')) + +// Do not truncate assertion errors +chai.config.truncateThreshold = 0 /* * Global stubs */ diff --git a/services/web/test/unit/common_bootstrap.js b/services/web/test/unit/common_bootstrap.js index d74fee60b2..a77aad61c6 100644 --- a/services/web/test/unit/common_bootstrap.js +++ b/services/web/test/unit/common_bootstrap.js @@ -1,22 +1,3 @@ -const chai = require('chai') - -/* - * Chai configuration - */ - -// add chai.should() -chai.should() - -// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') -// has a nicer failure messages -chai.use(require('sinon-chai')) - -// Load promise support for chai -chai.use(require('chai-as-promised')) - -// Do not truncate assertion errors -chai.config.truncateThreshold = 0 - // add support for mongoose in sinon require('sinon-mongoose') diff --git a/services/web/test/unit/vitest_bootstrap.mjs b/services/web/test/unit/vitest_bootstrap.mjs index 2244faefd3..5a39b2d587 100644 --- a/services/web/test/unit/vitest_bootstrap.mjs +++ b/services/web/test/unit/vitest_bootstrap.mjs @@ -1,8 +1,26 @@ -import { vi } from 'vitest' +import { chai, vi } from 'vitest' import './common_bootstrap.js' import sinon from 'sinon' import logger from '@overleaf/logger' +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' +/* + * Chai configuration + */ + +// add chai.should() +chai.should() + +// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') +// has a nicer failure messages +chai.use(sinonChai) + +// Load promise support for chai +chai.use(chaiAsPromised) + +// Do not truncate assertion errors +chai.config.truncateThreshold = 0 vi.mock('@overleaf/logger', async () => { return { default: { From e0f6ee8b206eab4f30a15aa57cf1ef4aa3746391 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 9 Jun 2025 10:16:41 +0100 Subject: [PATCH 069/209] Merge pull request #26133 from overleaf/mj-ide-keyboard-shortcuts [web] Editor redesign: Add keyboard shortcuts to menu bar GitOrigin-RevId: 8fe844389de70a919ba836d03f0390f585532bb1 --- .../context/command-registry-context.tsx | 133 +++++++++++++++++- .../components/toolbar/command-dropdown.tsx | 35 +++-- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx index e8bec19b8b..ff54c21f2a 100644 --- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx @@ -1,4 +1,11 @@ -import { createContext, useCallback, useContext, useState } from 'react' +import { isMac } from '@/shared/utils/os' +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' type CommandInvocationContext = { location?: string @@ -10,17 +17,21 @@ export type Command = { handler?: (context: CommandInvocationContext) => void href?: string disabled?: boolean - // TODO: Keybinding? } const CommandRegistryContext = createContext( undefined ) +export type Shortcut = { key: string } + +export type Shortcuts = Record + type CommandRegistry = { registry: Map register: (...elements: Command[]) => void unregister: (...id: string[]) => void + shortcuts: Shortcuts } export const CommandRegistryProvider: React.FC = ({ @@ -43,8 +54,35 @@ export const CommandRegistryProvider: React.FC = ({ ) }, []) + // NOTE: This is where we'd add functionality for customising shortcuts. + const shortcuts: Record = useMemo( + () => ({ + undo: [ + { + key: 'Mod-z', + }, + ], + redo: [ + { + key: 'Mod-y', + }, + { + key: 'Mod-Shift-Z', + }, + ], + find: [{ key: 'Mod-f' }], + 'select-all': [{ key: 'Mod-a' }], + 'insert-comment': [{ key: 'Mod-Shift-C' }], + 'format-bold': [{ key: 'Mod-b' }], + 'format-italics': [{ key: 'Mod-i' }], + }), + [] + ) + return ( - + {children} ) @@ -59,3 +97,92 @@ export const useCommandRegistry = (): CommandRegistry => { } return context } + +function parseShortcut(shortcut: Shortcut) { + // Based on KeyBinding type of CodeMirror 6 + let alt = false + let ctrl = false + let shift = false + let meta = false + + let character = null + // isMac ? shortcut.mac : shortcut.key etc. + const shortcutString = shortcut.key ?? '' + const keys = shortcutString.split(/-(?!$)/) ?? [] + + for (let i = 0; i < keys.length; i++) { + const isLast = i === keys.length - 1 + const key = keys[i] + if (!key) { + throw new Error('Empty key in shortcut: ' + shortcutString) + } + if (key === 'Alt' || (!isLast && key === 'a')) { + alt = true + } else if ( + key === 'Ctrl' || + key === 'Control' || + (!isLast && key === 'c') + ) { + ctrl = true + } else if (key === 'Shift' || (!isLast && key === 's')) { + shift = true + } else if (key === 'Meta' || key === 'Cmd' || (!isLast && key === 'm')) { + meta = true + } else if (key === 'Mod') { + if (isMac) { + meta = true + } else { + ctrl = true + } + } else { + if (key === 'Space') { + character = ' ' + } + if (!isLast) { + throw new Error( + 'Character key must be last in shortcut: ' + shortcutString + ) + } + if (key.length !== 1) { + throw new Error(`Invalid key '${key}' in shortcut: ${shortcutString}`) + } + if (character) { + throw new Error('Multiple characters in shortcut: ' + shortcutString) + } + character = key + } + } + if (!character) { + throw new Error('No character in shortcut: ' + shortcutString) + } + + return { + alt, + ctrl, + shift, + meta, + character, + } +} + +export const formatShortcut = (shortcut: Shortcut): string => { + const { alt, ctrl, shift, meta, character } = parseShortcut(shortcut) + + if (isMac) { + return [ + ctrl ? '⌃' : '', + alt ? '⌥' : '', + shift ? '⇧' : '', + meta ? '⌘' : '', + character.toUpperCase(), + ].join('') + } + + return [ + ctrl ? 'Ctrl' : '', + shift ? 'Shift' : '', + meta ? 'Meta' : '', + alt ? 'Alt' : '', + character.toUpperCase(), + ].join(' ') +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx index e08cf8873a..2dc696cdbf 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx @@ -1,5 +1,7 @@ import { Command, + formatShortcut, + Shortcuts, useCommandRegistry, } from '@/features/ide-react/context/command-registry-context' import { @@ -14,7 +16,10 @@ import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option' import { Fragment, useCallback, useMemo } from 'react' type CommandId = string -type TaggedCommand = Command & { type: 'command' } +type TaggedCommand = Command & { + type: 'command' + shortcuts?: Shortcuts[CommandId] +} type Entry = T | GroupStructure type GroupStructure = { id: string @@ -37,13 +42,13 @@ const CommandDropdown = ({ title: string id: string }) => { - const { registry } = useCommandRegistry() + const { registry, shortcuts } = useCommandRegistry() const populatedSections = useMemo( () => menu - .map(section => populateSectionOrGroup(section, registry)) + .map(section => populateSectionOrGroup(section, registry, shortcuts)) .filter(x => x.children.length > 0), - [menu, registry] + [menu, registry, shortcuts] ) if (populatedSections.length === 0) { @@ -76,8 +81,8 @@ export const CommandSection = ({ }: { section: MenuSectionStructure }) => { - const { registry } = useCommandRegistry() - const section = populateSectionOrGroup(sectionStructure, registry) + const { registry, shortcuts } = useCommandRegistry() + const section = populateSectionOrGroup(sectionStructure, registry, shortcuts) if (section.children.length === 0) { return null } @@ -108,6 +113,9 @@ const CommandDropdownChild = ({ item }: { item: Entry }) => { onClick={onClickHandler} href={item.href} disabled={item.disabled} + trailingIcon={ + item.shortcuts && {formatShortcut(item.shortcuts[0])} + } /> ) } else { @@ -127,7 +135,8 @@ function populateSectionOrGroup< T extends { children: Array> }, >( section: T, - registry: Map + registry: Map, + shortcuts: Shortcuts ): Omit & { children: Array> } { @@ -137,7 +146,11 @@ function populateSectionOrGroup< children: children .map(child => { if (typeof child !== 'string') { - const populatedChild = populateSectionOrGroup(child, registry) + const populatedChild = populateSectionOrGroup( + child, + registry, + shortcuts + ) if (populatedChild.children.length === 0) { // Skip empty groups return undefined @@ -146,7 +159,11 @@ function populateSectionOrGroup< } const command = registry.get(child) if (command) { - return { ...command, type: 'command' as const } + return { + ...command, + shortcuts: shortcuts[command.id], + type: 'command' as const, + } } return undefined }) From d3a9b4943a22bb8e670abc48e3a29d7ab3c8f09a Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 9 Jun 2025 10:16:49 +0100 Subject: [PATCH 070/209] Merge pull request #26257 from overleaf/mj-ide-breadcrumbs-crash [web] Avoid editor crash when breadcrumbs can't find open entity GitOrigin-RevId: 7c7f198c82e102ee9f8e2a59ca1755c3550bdf37 --- .../context/online-users-context.tsx | 2 +- .../ide-redesign/components/breadcrumbs.tsx | 33 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx index 1dba40e6d7..1195f9ae7c 100644 --- a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx @@ -95,7 +95,7 @@ export const OnlineUsersProvider: FC = ({ for (const [clientId, user] of Object.entries(onlineUsers)) { const decoratedUser = { ...user } const docId = user.doc_id - if (docId) { + if (docId && fileTreeData) { decoratedUser.doc = findDocEntityById(fileTreeData, docId) } diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx index f148e0142e..9949b98c7f 100644 --- a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx @@ -1,4 +1,7 @@ -import { findInTreeOrThrow } from '@/features/file-tree/util/find-in-tree' +import { + findInTree, + findInTreeOrThrow, +} from '@/features/file-tree/util/find-in-tree' import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' import { useOutlineContext } from '@/features/ide-react/context/outline-context' import useNestedOutline from '@/features/outline/hooks/use-nested-outline' @@ -39,35 +42,41 @@ export default function Breadcrumbs() { const { highlightedLine, canShowOutline } = useOutlineContext() const folderHierarchy = useMemo(() => { - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return [] } - return openEntity.path - .filter(id => id !== fileTreeData._id) // Filter out the root folder - .map(id => { - return findInTreeOrThrow(fileTreeData, id)?.entity - }) + try { + return openEntity.path + .filter(id => id !== fileTreeData._id) // Filter out the root folder + .map(id => { + return findInTreeOrThrow(fileTreeData, id)?.entity + }) + } catch { + // If any of the folders in the path are not found, the entire hierarchy + // is invalid. + return [] + } }, [openEntity, fileTreeData]) const fileName = useMemo(() => { // NOTE: openEntity.entity.name may not always be accurate, so we read it // from the file tree data instead. - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return undefined } - return findInTreeOrThrow(fileTreeData, openEntity.entity._id)?.entity.name + return findInTree(fileTreeData, openEntity.entity._id)?.entity.name }, [fileTreeData, openEntity]) const outlineHierarchy = useMemo(() => { - if (!canShowOutline || !outline) { + if (openEntity?.type !== 'doc' || !canShowOutline || !outline) { return [] } return constructOutlineHierarchy(outline.items, highlightedLine) - }, [outline, highlightedLine, canShowOutline]) + }, [outline, highlightedLine, canShowOutline, openEntity]) - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return null } From ff63215d73c538798f6d8d55f5e8a928416a090d Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:18:36 +0100 Subject: [PATCH 071/209] Merge pull request #26155 from overleaf/dp-content-info Add content-info and content-info-dark to standard colours and use in editor redesign logs GitOrigin-RevId: 40c026a9ccfe511cab2bf4e28fbfbed7cf218642 --- .../bootstrap-5/abstracts/themes-common-variables.scss | 2 ++ .../frontend/stylesheets/bootstrap-5/foundations/colors.scss | 4 ++++ .../frontend/stylesheets/bootstrap-5/pages/editor/logs.scss | 4 +--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss index 562dfb3efd..969a861c67 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss @@ -12,6 +12,7 @@ --content-danger-themed: var(--content-danger-dark); --content-warning-themed: var(--content-warning-dark); --content-positive-themed: var(--content-positive-dark); + --content-info-themed: var(--content-info-dark); --border-primary-themed: var(--border-primary-dark); --border-hover-themed: var(--border-hover-dark); --border-disabled-themed: var(--border-disabled-dark); @@ -39,6 +40,7 @@ --content-danger-themed: var(--content-danger); --content-warning-themed: var(--content-warning); --content-positive-themed: var(--content-positive); + --content-info-themed: var(--content-info); --border-primary-themed: var(--border-primary); --border-hover-themed: var(--border-hover); --border-disabled-themed: var(--border-disabled); diff --git a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss index 73e93273c4..9d0bd2ac95 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss @@ -83,6 +83,7 @@ $content-placeholder: $neutral-60; $content-danger: $red-50; $content-warning: $yellow-50; $content-positive: $green-50; +$content-info: $blue-50; $border-primary: $neutral-60; $border-hover: $neutral-70; $border-disabled: $neutral-20; @@ -102,6 +103,7 @@ $content-placeholder-dark: $neutral-50; $content-danger-dark: $red-40; $content-warning-dark: $yellow-40; $content-positive-dark: $green-40; +$content-info-dark: $blue-30; $border-primary-dark: $neutral-30; $border-hover-dark: $neutral-20; $border-disabled-dark: $neutral-80; @@ -193,6 +195,7 @@ $link-ui-visited-dark: $blue-40; --content-danger: var(--red-50); --content-warning: var(--yellow-50); --content-positive: var(--green-50); + --content-info: var(--blue-50); --border-primary: var(--neutral-60); --border-hover: var(--neutral-70); --border-disabled: var(--neutral-20); @@ -213,6 +216,7 @@ $link-ui-visited-dark: $blue-40; --content-danger-dark: var(--red-40); --content-warning-dark: var(--yellow-40); --content-positive-dark: var(--green-40); + --content-info-dark: var(--blue-30); --border-primary-dark: var(--neutral-30); --border-hover-dark: var(--neutral-20); --border-disabled-dark: var(--neutral-80); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss index 06f97545d3..95c5a83ddc 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss @@ -1,11 +1,9 @@ :root { --logs-pane-bg: var(--bg-dark-secondary); - --logs-info-color: var(--blue-40); } @include theme('light') { --logs-pane-bg: var(--bg-light-secondary); - --logs-info-color: var(--blue-60); } .ide-redesign-main { @@ -106,7 +104,7 @@ } .log-entry-header-text-info { - color: var(--logs-info-color); + color: var(--content-info-themed); } .log-entry-header-text-success { From 45c6ce221972afba7e6e72ddca66202683854de8 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Mon, 9 Jun 2025 11:17:52 +0100 Subject: [PATCH 072/209] Merge pull request #25842 from overleaf/ds-cms-bs5-migration-enterprises-2 [B2C] Bootstrap 5 migration of Enterprises page GitOrigin-RevId: 63c4095ddb2ee688bc1780883b86f5a994b262c0 --- .../bootstrap-5/pages/website-redesign.scss | 395 +++++++++++++++++- 1 file changed, 392 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 2e069d0599..7ee9a98c35 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -258,6 +258,7 @@ .round-background { border-radius: 50%; vertical-align: middle; + margin-right: var(--spacing-04); width: 20px; height: 20px; } @@ -292,11 +293,11 @@ .resources-card { display: flex; flex-flow: column wrap; - margin-bottom: 48px; + margin-bottom: var(--spacing-11); align-content: flex-start; @include media-breakpoint-down(lg) { - margin-bottom: 16px; + margin-bottom: var(--spacing-06); } img { @@ -384,7 +385,114 @@ } } + .inline-green-link { + color: var(--green-50); + padding: 0; + text-decoration: underline; + + // text-decoration-skip-ink is for letters with descenders (like 'g' and 'y') + // this will force underline to not skip the descender + text-decoration-skip-ink: none; + + &:hover { + color: var(--green-60); + } + + // TODO: this is copied directly from the `.less` file, migrate this to scss + // &:focus { + // @extend .input-focus-style; + // } + } + + .customer-story-card-title { + @include heading-md; + + margin-top: var(--spacing-08); + margin-bottom: var(--spacing-05); + } + + .plans-bottom-text { + font-size: var(--font-size-04); + } + + .plans-cards { + @include media-breakpoint-up(lg) { + display: flex; + } + + .plans-card-container { + min-height: 348px; + padding-left: var(--spacing-05); + padding-right: var(--spacing-05); + + @include media-breakpoint-down(lg) { + margin-bottom: var(--spacing-06); + min-height: unset; + } + } + + .plans-card { + border-radius: 8px; + padding: 0; + height: 100%; + + .plans-card-inner { + padding: var(--spacing-09); + height: 100%; + display: flex; + flex-direction: column; + font-size: var(--font-size-03); + + .plans-card-inner-title { + font-size: var(--font-size-05); + line-height: var(--line-height-04); + font-weight: 600; + margin-top: 0; + } + + ul { + list-style-type: none; + padding: 0; + margin: 0; + + li { + margin-bottom: var(--spacing-04); + } + } + + .plans-card-inner-footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-05); + + @include media-breakpoint-down(lg) { + margin-top: var(--spacing-06); + } + } + } + + &.grey-border { + border: 2px solid var(--neutral-20); + } + + &.blue-border { + border: solid 2px var(--sapphire-blue); + border-radius: 8px; + + .plans-card-inner-title { + color: var(--sapphire-blue); + } + } + } + } + .heading-section-md-align-left { + h2, + p { + text-align: center; + } + @include media-breakpoint-down(lg) { display: flex; flex-direction: column; @@ -410,7 +518,7 @@ } &.align-left-button-sm { - @include media-breakpoint-down(md) { + @include media-breakpoint-down(lg) { justify-content: start; } } @@ -421,6 +529,28 @@ } } + .editor-pdf-video { + display: flex; + align-items: center; + justify-content: center; + height: 585px; + padding: 0 var(--spacing-06); + + @include media-breakpoint-down(lg) { + height: auto; + } + + video { + box-shadow: 0 60px 25px -15px rgb(16 24 40 / 20%); + max-height: 100%; + width: auto; + + @include media-breakpoint-down(lg) { + width: 100%; + } + } + } + .overleaf-sticker { width: unset; @@ -429,6 +559,130 @@ } } + .organization-logos-container { + display: flex; + justify-content: space-around; + align-items: center; + + @include media-breakpoint-down(xl) { + flex-wrap: wrap; + gap: 30px; + } + + .organization-logo { + object-fit: contain; + max-height: 62px; + + &.samsung-logo { + max-height: 110px; + height: 110px; + } + + @include media-breakpoint-down(xl) { + max-height: 40px; + flex-basis: 34%; + } + } + } + + .integrations-card { + display: flex; + + /* for center align */ + flex-wrap: wrap; + align-items: center; + + .integrations-icons { + img { + width: 6rem; // 96px + height: 6rem; // 96px + } + + .first-row, + .second-row { + display: flex; + } + + .first-row { + justify-content: space-between; + } + + .second-row { + margin-top: var(--spacing-10); + justify-content: space-evenly; + } + } + } + + .security-info { + .security-info-first-row { + margin-bottom: var(--spacing-09); + + @include media-breakpoint-down(lg) { + margin-bottom: 0; + } + } + + .security-info-item { + @include media-breakpoint-down(lg) { + margin-bottom: var(--spacing-06); + } + } + + h3 { + @include heading-sm; + } + } + + .security-heading-section { + @include media-breakpoint-down(lg) { + p { + text-align: left; + } + + h2 { + width: 100%; + text-align: left; + } + } + + .heading-and-stickers-container { + display: flex; + justify-content: center; + position: relative; + + .lock-sticker { + width: 70px; + position: absolute; + top: -95px; + right: -50px; + + @include media-breakpoint-down(xl) { + right: -105px; + } + + @include media-breakpoint-down(lg) { + display: none; + } + } + + .arrow-sticker { + width: 140px; + position: absolute; + top: -50px; + right: -15px; + + @include media-breakpoint-down(xl) { + right: -70px; + } + + @include media-breakpoint-down(lg) { + display: none; + } + } + } + } + .features-card { display: flex; /* equal heights */ flex-wrap: wrap; @@ -528,4 +782,139 @@ } } } + + .features-card-hero { + display: flex; + + /* equal heights */ + flex-wrap: wrap; + align-items: center; + position: relative; + height: 655px; + + // padding-top: @line-height-computed * 2; + + @include media-breakpoint-down(lg) { + height: unset; + padding-top: 0; + } + + .features-card-description { + display: flex; + flex-direction: column; + justify-content: center; + + h1 { + &.features-card-hero-smaller-title { + @include media-breakpoint-up(xl) { + // 3rem is the default, this is a workaround for big screen + // since 6-width column on md screen size will wrap the text in three lines + font-size: var(--font-size-09); + } + } + } + + p { + font-size: var(--font-size-05); + width: 90%; + + @include media-breakpoint-down(lg) { + font-size: var(--font-size-04); + line-height: var(--line-height-03); + width: unset; + } + } + } + + .features-card-image { + position: absolute; + + // on wide screen, image will be fixed without any variable width translation + transform: translateX(600px); + top: 100px; + width: 720px; + height: auto; + padding: 0 15px; + + // starting from 1500px, image will have a variable translation that depends on screen width + // this will make image "fixed" on a specific point on the screen + @media (width <= 1500px) { + transform: translateX(calc(50vw - 121px)); + } + + @media (width <= 1400px) { + width: 650px; + transform: translateX(calc(50vw - 52px)); + } + + // bootstrap layout changes on 1200px (@screen-lg), add a specific + // case for this exact width + @media (width >= 1200px) and (width <= 1200px) { + width: 600px; + transform: translateX(calc(50vw)); + } + + @media (width <= 1199px) { + width: 600px; + transform: translateX(calc(50vw - 106px)); + } + + @media (width <= 1100px) { + width: 550px; + transform: translateX(calc(50vw - 55px)); + } + + // 991px + @include media-breakpoint-down(lg) { + position: relative; + transform: none; + top: 0; + width: 100%; + margin-bottom: var(--spacing-11); + padding: var(--spacing-09) 0 0 0; + } + + img.img-responsive { + width: 100%; + } + } + + .sticky-tags { + position: absolute; + z-index: 2; + height: 160px; + bottom: -105px; + right: 55px; + + @media (width <= 1400px) { + height: 150px; + bottom: -103px; + right: 47px; + } + + @media (width <= 1200px) { + height: 130px; + bottom: -87px; + } + + @media (width <= 1100px) { + height: 120px; + bottom: -81px; + } + + // 991px + @include media-breakpoint-down(lg) { + height: 130px; + bottom: -75px; + right: 70px; + } + + // 767px + @include media-breakpoint-down(md) { + height: 24%; + bottom: -10vw; // scale with width + right: 9.5vw; // scale with width + } + } + } } From 86626ca44e8fce845245c0ba7cc6b77c736be856 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Mon, 9 Jun 2025 11:18:11 +0100 Subject: [PATCH 073/209] Merge pull request #25856 from overleaf/ds-cms-bs5-migration-universities-2 [B2C] Bootstrap 5 migration of Universities page GitOrigin-RevId: b069c04131531e9f9774a9a53aaa53858ba568c7 --- .../stylesheets/bootstrap-5/pages/website-redesign.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 7ee9a98c35..1f6027d835 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -555,7 +555,7 @@ width: unset; @include media-breakpoint-down(lg) { - width: 74px; // 70% of 106px + width: 106px; } } From 5b08adc4ff18ff4e2d6a82c2c73fbc94b1b22987 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Mon, 9 Jun 2025 13:23:42 +0200 Subject: [PATCH 074/209] Merge pull request #26218 from overleaf/msm-bump-tar-fs-multer [clsi/web/history-v1] Bump `tar-fs` and `multer` GitOrigin-RevId: c76b964224c8367d68dc1190ff29627cc6919ade --- package-lock.json | 1678 +++++++++++++++++++++++------------- package.json | 2 +- services/clsi/package.json | 4 +- services/web/package.json | 2 +- 4 files changed, 1086 insertions(+), 600 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce941a1670..c0967e0977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5943,15 +5943,16 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@grpc/grpc-js": { - "version": "1.8.22", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.8.22.tgz", - "integrity": "sha512-oAjDdN7fzbUi+4hZjKG96MR6KTEubAeMpQEb+77qy+3r0Ua5xTFuie6JOLr4ZZgl5g+W5/uRTS2M1V8mVAFPuA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=12.10.0" } }, "node_modules/@grpc/proto-loader": { @@ -6989,6 +6990,18 @@ "dev": true, "optional": true }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@node-oauth/formats": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@node-oauth/formats/-/formats-1.0.0.tgz", @@ -8643,6 +8656,15 @@ "resolved": "services/web", "link": true }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@phosphor-icons/react": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.7.tgz", @@ -15229,13 +15251,13 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" @@ -15351,19 +15373,18 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -15457,6 +15478,15 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -16026,24 +16056,32 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", - "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-events": "^2.0.0", + "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.0.0" + "bare-stream": "^2.6.4" }, "engines": { - "bare": ">=1.7.0" + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-os": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.0.tgz", - "integrity": "sha512-BUrFS5TqSBdA0LwHop4OjPJwisqxGy6JsWVqV6qaFoe965qqtaKfDzHY5T2YA1gUL0ZeeQeA+4BBc1FJTcHiPw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "license": "Apache-2.0", "optional": true, "engines": { @@ -16925,15 +16963,44 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -17422,7 +17489,8 @@ "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" }, "node_modules/chrome-trace-event": { "version": "1.0.3", @@ -17780,12 +17848,10 @@ "license": "MIT" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "engines": { - "node": ">= 6" - } + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "license": "MIT" }, "node_modules/common-path-prefix": { "version": "3.0.0", @@ -17900,46 +17966,20 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ - "node >= 0.8" + "node >= 6.0" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", - "readable-stream": "^2.2.2", + "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -18385,6 +18425,20 @@ "node": ">=10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -19430,14 +19484,14 @@ } }, "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -19447,29 +19501,29 @@ } }, "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, @@ -19880,7 +19934,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -19952,6 +20005,88 @@ "node": ">=6" } }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.7.tgz", + "integrity": "sha512-R+rgrSRTRdU5mH14PZTCPZtW/zw3HDWNTS/1ZAQpL/5Upe/ye5K9WQkIysu4wBoiMwKynsz0a8qWuGsHgEvSAA==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "~2.1.2", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -20179,6 +20314,20 @@ "node": ">=0.10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", @@ -20510,57 +20659,65 @@ } }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", + "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -20570,12 +20727,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -20616,9 +20771,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -20628,14 +20783,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -20651,13 +20807,14 @@ } }, "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -22812,8 +22969,7 @@ "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "node_modules/fast-text-encoding": { "version": "1.0.3", @@ -23308,11 +23464,18 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -23474,6 +23637,7 @@ "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "license": "MIT", "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -23649,14 +23813,17 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -23768,15 +23935,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -23804,6 +23977,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -23820,14 +24006,14 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -24047,11 +24233,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -24598,11 +24786,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -24622,6 +24811,7 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", "dependencies": { "lodash": "^4.17.15" } @@ -24842,10 +25032,13 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -24854,9 +25047,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -25814,14 +26008,14 @@ } }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -25999,13 +26193,14 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -26020,12 +26215,35 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", "dependencies": { - "has-bigints": "^1.0.1" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -26044,12 +26262,13 @@ } }, "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26114,11 +26333,13 @@ } }, "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { @@ -26129,11 +26350,13 @@ } }, "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26198,6 +26421,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -26295,10 +26533,13 @@ } }, "node_modules/is-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -26360,11 +26601,13 @@ } }, "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26423,12 +26666,15 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -26438,10 +26684,13 @@ } }, "node_modules/is-set": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -26454,12 +26703,12 @@ "license": "MIT" }, "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -26480,11 +26729,13 @@ } }, "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26494,11 +26745,14 @@ } }, "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -26508,12 +26762,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -26554,33 +26808,43 @@ "integrity": "sha512-X/kiF3Xndj6WI7l/yLyzR7V1IbQd6L4S4cewSL0fRciemPmHbaXIKR2qtf+zseH+lbMG0vFp4HvCUe7amGZVhw==" }, "node_modules/is-weakmap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", - "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -27301,6 +27565,7 @@ "version": "3.0.15", "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", "integrity": "sha512-0vOQd9eLNBL18EGl5yYaO44GhixmImes2wiYn9Z3sag3QnehWrYWlB9AFtMxCL2Bj3fyxgDYkxGFEU/chlYssw==", + "license": "MIT", "dependencies": { "commander": "~4.1.1", "graphlib": "^2.1.8", @@ -27322,14 +27587,25 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, + "node_modules/json-refs/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/json-refs/node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -27342,6 +27618,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", "engines": { "node": ">=8" } @@ -28107,12 +28384,14 @@ "node_modules/lodash._arraypool": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._arraypool/-/lodash._arraypool-2.4.1.tgz", - "integrity": "sha1-6I7suS4ruEyQZWEv2VigcZzUf5Q=" + "integrity": "sha512-tC2aLC7bbkDXKNrjDu9OLiVx9pFIvjinID2eD9PzNdAQGZScWUd/h8faqOw5d6oLsOvFRCRbz1ASoB+deyMVUw==", + "license": "MIT" }, "node_modules/lodash._basebind": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basebind/-/lodash._basebind-2.4.1.tgz", - "integrity": "sha1-6UC5690nwyfgqNqxtVkWxTQelXU=", + "integrity": "sha512-VGHm6DH+1UiuafQdE/DNMqxOcSyhRu0xO9+jPDq7xITRn5YOorGrHVQmavMVXCYmTm80YRTZZCn/jTW7MokwLg==", + "license": "MIT", "dependencies": { "lodash._basecreate": "~2.4.1", "lodash._setbinddata": "~2.4.1", @@ -28123,7 +28402,8 @@ "node_modules/lodash._baseclone": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-2.4.1.tgz", - "integrity": "sha1-MPgj5X4X43NdODvWK2Czh1Q7QYY=", + "integrity": "sha512-+zJVXs0VxC/Au+/7foiKzw8UaWvfSfPh20XhqK/6HFQiUeclL5fz05zY7G9yDAFItAKKZwB4cgpzGvxiwuG1wQ==", + "license": "MIT", "dependencies": { "lodash._getarray": "~2.4.1", "lodash._releasearray": "~2.4.1", @@ -28138,7 +28418,8 @@ "node_modules/lodash._basecreate": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-2.4.1.tgz", - "integrity": "sha1-+Ob1tXip405UEXm1a47uv0oofgg=", + "integrity": "sha512-8JJ3FnMPm54t3BwPLk8q8mPyQKQXm/rt9df+awr4NGtyJrtcCXM3Of1I86S6jVy1b4yAyFBb8wbKPEauuqzRmQ==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash.isobject": "~2.4.1", @@ -28148,7 +28429,8 @@ "node_modules/lodash._basecreatecallback": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreatecallback/-/lodash._basecreatecallback-2.4.1.tgz", - "integrity": "sha1-fQsmdknLKeehOdAQO3wR+uhOSFE=", + "integrity": "sha512-SLczhg860fGW7AKlYcuOFstDtJuQhaANlJ4Y/jrOoRxhmVtK41vbJDH3OefVRSRkSCQo4HI82QVkAVsoGa5gSw==", + "license": "MIT", "dependencies": { "lodash._setbinddata": "~2.4.1", "lodash.bind": "~2.4.1", @@ -28159,7 +28441,8 @@ "node_modules/lodash._basecreatewrapper": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreatewrapper/-/lodash._basecreatewrapper-2.4.1.tgz", - "integrity": "sha1-TTHy595+E0+/KAN2K4FQsyUZZm8=", + "integrity": "sha512-x2ja1fa/qmzbizuXgVM4QAP9svtMbdxjG8Anl9bCeDAwLOVQ1vLrA0hLb/NkpbGi9evjtkl0aWLTEoOlUdBPQA==", + "license": "MIT", "dependencies": { "lodash._basecreate": "~2.4.1", "lodash._setbinddata": "~2.4.1", @@ -28170,7 +28453,8 @@ "node_modules/lodash._createwrapper": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._createwrapper/-/lodash._createwrapper-2.4.1.tgz", - "integrity": "sha1-UdaVeXPaTtVW43KQ2MGhjFPeFgc=", + "integrity": "sha512-5TCfLt1haQpsa7bgLYRKNNE4yqhO4ZxIayN1btQmazMchO6Q8JYFRMqbJ3W+uNmMm4R0Jw7KGkZX5YfDDnywuw==", + "license": "MIT", "dependencies": { "lodash._basebind": "~2.4.1", "lodash._basecreatewrapper": "~2.4.1", @@ -28181,7 +28465,8 @@ "node_modules/lodash._getarray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._getarray/-/lodash._getarray-2.4.1.tgz", - "integrity": "sha1-+vH3+BD6mFolHCGHQESBCUg55e4=", + "integrity": "sha512-iIrScwY3atGvLVbQL/+CNUznaPwBJg78S/JO4cTUFXRkRsZgEBhscB27cVoT4tsIOUyFu/5M/0umfHNGJ6wYwg==", + "license": "MIT", "dependencies": { "lodash._arraypool": "~2.4.1" } @@ -28189,22 +28474,26 @@ "node_modules/lodash._isnative": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz", - "integrity": "sha1-PqZAS3hKe+g2x7V1gOHN95sUgyw=" + "integrity": "sha512-BOlKGKNHhCHswGOWtmVb5zBygyxN7EmTuzVOSQI6QSoGhG+kvv71gICFS1TBpnqvT1n53txK8CDK3u5D2/GZxQ==", + "license": "MIT" }, "node_modules/lodash._maxpoolsize": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._maxpoolsize/-/lodash._maxpoolsize-2.4.1.tgz", - "integrity": "sha1-nUgvRjuOZq++WcLBTtsRcGAXIzQ=" + "integrity": "sha512-xKDem1BxoIfcCtaJHotjtyfdIvZO9qrF+mv3G1+ngQmaI3MJt3Qm46i9HLk/CbzABbavUrr1/EomQT8KxtsrYA==", + "license": "MIT" }, "node_modules/lodash._objecttypes": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz", - "integrity": "sha1-fAt/admKH3ZSn4kLDNsbTf7BHBE=" + "integrity": "sha512-XpqGh1e7hhkOzftBfWE7zt+Yn9mVHFkDhicVttvKLsoCMLVVL+xTQjfjB4X4vtznauxv0QZ5ZAeqjvat0dh62Q==", + "license": "MIT" }, "node_modules/lodash._releasearray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._releasearray/-/lodash._releasearray-2.4.1.tgz", - "integrity": "sha1-phOWMNdtFTawfdyAliiJsIL2pkE=", + "integrity": "sha512-wwCwWX8PK/mYR5VZjcU5JFl6py/qrfLGMxzpKOfSqgA1PaZ6Z625CZLCxH1KsqyxSkOFmNm+mEYjeDpXlM4hrg==", + "license": "MIT", "dependencies": { "lodash._arraypool": "~2.4.1", "lodash._maxpoolsize": "~2.4.1" @@ -28213,7 +28502,8 @@ "node_modules/lodash._setbinddata": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._setbinddata/-/lodash._setbinddata-2.4.1.tgz", - "integrity": "sha1-98IAzRuS7yNrOZ7s9zxkjReqlNI=", + "integrity": "sha512-Vx0XKzpg2DFbQw4wrp1xSWd2sfl3W/BG6bucSRZmftS1AzbWRemCmBQDxyQTNhlLNec428PXkuuja+VNBZgu2A==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash.noop": "~2.4.1" @@ -28222,7 +28512,8 @@ "node_modules/lodash._shimkeys": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz", - "integrity": "sha1-bpzJZm/wgfC1psl4uD4kLmlJ0gM=", + "integrity": "sha512-lBrglYxLD/6KAJ8IEa5Lg+YHgNAL7FyKqXg4XOUI+Du/vtniLs1ZqS+yHNKPkK54waAgkdUnDOYaWf+rv4B+AA==", + "license": "MIT", "dependencies": { "lodash._objecttypes": "~2.4.1" } @@ -28230,12 +28521,14 @@ "node_modules/lodash._slice": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._slice/-/lodash._slice-2.4.1.tgz", - "integrity": "sha1-dFz0GlNZexj2iImFREBe+isG2Q8=" + "integrity": "sha512-+odPJa4PE2UgYnQgJgkLs0UD03QU78R2ivhrFnG9GdtYOZdE6ObxOj7KiUEUlqOOgatFT+ZqSypFjDSduTigKg==", + "license": "MIT" }, "node_modules/lodash.assign": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-2.4.1.tgz", - "integrity": "sha1-hMOVlt1xGBqXsGUpE6fJZ15Jsao=", + "integrity": "sha512-AqQ4AJz5buSx9ELXWt5dONwJyVPd4NTADMKhoVYWCugjoVf172/LpvVhwmSJn4g8/Dc0S8hxTe8rt5Dob3X9KQ==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash._objecttypes": "~2.4.1", @@ -28245,7 +28538,8 @@ "node_modules/lodash.bind": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-2.4.1.tgz", - "integrity": "sha1-XRn6AFyMTSNvr0dCx7eh/Kvikmc=", + "integrity": "sha512-hn2VWYZ+N9aYncRad4jORvlGgpFrn+axnPIWRvFxjk6CWcZH5b5alI8EymYsHITI23Z9wrW/+ORq+azrVFpOfw==", + "license": "MIT", "dependencies": { "lodash._createwrapper": "~2.4.1", "lodash._slice": "~2.4.1" @@ -28259,7 +28553,8 @@ "node_modules/lodash.clonedeep": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-2.4.1.tgz", - "integrity": "sha1-8pIDtAsS/uCkXTYxZIJZvrq8eGg=", + "integrity": "sha512-zj5vReFLkR+lJOBKP1wyteZ13zut/KSmXtdCBgxcy/m4UTitcBxpeVZT7gwk8BQrztPI5dIgO4bhBppXV4rpTQ==", + "license": "MIT", "dependencies": { "lodash._baseclone": "~2.4.1", "lodash._basecreatecallback": "~2.4.1" @@ -28289,7 +28584,8 @@ "node_modules/lodash.foreach": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-2.4.1.tgz", - "integrity": "sha1-/j/Do0yGyUyrb5UiVgKCdB4BYwk=", + "integrity": "sha512-AvOobAkE7qBtIiHU5QHQIfveWH5Usr9pIcFIzBv7u4S6bvb3FWpFrh9ltqBY7UeL5lw6e8d+SggiUXQVyh+FpA==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash.forown": "~2.4.1" @@ -28298,7 +28594,8 @@ "node_modules/lodash.forown": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-2.4.1.tgz", - "integrity": "sha1-eLQer+FAX6lmRZ6kGT/VAtCEUks=", + "integrity": "sha512-VC+CKm/zSs5t3i/MHv71HZoQphuqOvez1xhjWBwHU5zAbsCYrqwHr+MyQyMk14HzA3hSRNA5lCqDMSw5G2Qscg==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash._objecttypes": "~2.4.1", @@ -28319,7 +28616,8 @@ "node_modules/lodash.identity": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-2.4.1.tgz", - "integrity": "sha1-ZpTP+mX++TH3wxzobHRZfPVg9PE=" + "integrity": "sha512-VRYX+8XipeLjorag5bz3YBBRJ+5kj8hVBzfnaHgXPZAVTYowBdY5l0M5ZnOmlAMCOXBFabQtm7f5VqjMKEji0w==", + "license": "MIT" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -28334,7 +28632,8 @@ "node_modules/lodash.isarray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-2.4.1.tgz", - "integrity": "sha1-tSoybB9i9tfac6MdVAHfbvRPD6E=", + "integrity": "sha512-yRDd0z+APziDqbk0MqR6Qfwj/Qn3jLxFJbI9U8MuvdTnqIXdZ5YXyGLnwuzCpZmjr26F1GNOjKLMMZ10i/wy6A==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1" } @@ -28347,12 +28646,15 @@ "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" }, "node_modules/lodash.isfunction": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-2.4.1.tgz", - "integrity": "sha1-LP1XXHPkmKtX4xm3f6Aq3vE6lNE=" + "integrity": "sha512-6XcAB3izeQxPOQQNAJbbdjXbvWEt2Pn9ezPrjr4CwoLwmqsLVbsiEXD19cmmt4mbzOCOCdHzOQiUivUOJLra7w==", + "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", @@ -28367,7 +28669,8 @@ "node_modules/lodash.isobject": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz", - "integrity": "sha1-Wi5H/mmVPx7mMafrof5k0tBlWPU=", + "integrity": "sha512-sTebg2a1PoicYEZXD5PBdQcTlIJ6hUslrlWr7iV0O7n+i4596s2NQ9I5CaZ5FbXSfya/9WQsrYLANUJv9paYVA==", + "license": "MIT", "dependencies": { "lodash._objecttypes": "~2.4.1" } @@ -28385,7 +28688,8 @@ "node_modules/lodash.keys": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz", - "integrity": "sha1-SN6kbfj/djKxDXBrissmWR4rNyc=", + "integrity": "sha512-ZpJhwvUXHSNL5wYd1RM6CUa2ZuqorG9ngoJ9Ix5Cce+uX7I5O/E06FCJdhSZ33b5dVyeQDnIlWH7B2s5uByZ7g==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash._shimkeys": "~2.4.1", @@ -28406,7 +28710,8 @@ "node_modules/lodash.noop": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-2.4.1.tgz", - "integrity": "sha1-T7VPgWZS5a4Q6PcvcXo4jHMmU4o=" + "integrity": "sha512-uNcV98/blRhInPUGQEnj9ekXXfG+q+rfoNSFZgl/eBfog9yBDW9gfUv2AHX/rAF7zZRlzWhbslGhbGQFZlCkZA==", + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -28422,7 +28727,8 @@ "node_modules/lodash.support": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz", - "integrity": "sha1-Mg4LZwMWc8KNeiu12eAzGkUkBRU=", + "integrity": "sha512-6SwqWwGFHhTXEiqB/yQgu8FYd//tm786d49y7kizHVCJH7zdzs191UQn3ES3tkkDbUddNRfkCRYqJFHtbLnbCw==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1" } @@ -28812,6 +29118,15 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathjax": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz", @@ -29425,7 +29740,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, "bin": { "mime": "cli.js" }, @@ -29434,9 +29748,10 @@ } }, "node_modules/mime-db": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", - "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -29452,11 +29767,12 @@ } }, "node_modules/mime-types": { - "version": "2.1.34", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", - "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "mime-db": "1.51.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" @@ -29693,7 +30009,8 @@ "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" }, "node_modules/mlly": { "version": "1.7.4", @@ -30158,18 +30475,18 @@ } }, "node_modules/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-bS8rPZurbAuHGAnApbM9d4h1wSoYqrOqkE+6a64KLMK9yWU7gJXBDDVklKQ3TPi9DRb85cRs6yXaC0+cjxRtRg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "type-is": "^1.6.18", + "xtend": "^4.0.2" }, "engines": { "node": ">= 10.16.0" @@ -30299,7 +30616,8 @@ "node_modules/native-promise-only": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=" + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" }, "node_modules/native-request": { "version": "1.1.0", @@ -30853,9 +31171,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -30897,14 +31219,16 @@ } }, "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -31155,6 +31479,23 @@ "resolved": "libraries/overleaf-editor-core", "link": true }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-event": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", @@ -31714,12 +32055,80 @@ } }, "node_modules/path-loader": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.10.tgz", - "integrity": "sha512-CMP0v6S6z8PHeJ6NFVyVJm6WyJjIwFvyz2b0n2/4bKdS/0uZa/9sKUlYZzubrn3zuDRU0zIuEDX9DZYQ2ZI8TA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.12.tgz", + "integrity": "sha512-n7oDG8B+k/p818uweWrOixY9/Dsr89o2TkCm6tOTex3fpdo2+BFDgR+KpB37mGKBRsBAlR8CIJMFN0OEy/7hIQ==", + "license": "MIT", "dependencies": { "native-promise-only": "^0.8.1", - "superagent": "^3.8.3" + "superagent": "^7.1.6" + } + }, + "node_modules/path-loader/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/path-loader/node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/path-loader/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-loader/node_modules/superagent": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", + "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", + "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" } }, "node_modules/path-parse": { @@ -35056,6 +35465,28 @@ "node": ">=4.0.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -35120,15 +35551,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -35643,14 +36076,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { @@ -35671,6 +36105,22 @@ "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", "optional": true }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -35681,14 +36131,14 @@ } }, "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -36406,13 +36856,29 @@ } }, "node_modules/set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -36523,14 +36989,69 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -37109,7 +37630,8 @@ "node_modules/spark-md5": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", - "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "license": "(WTFPL OR MIT)" }, "node_modules/sparse-bitfield": { "version": "3.0.3", @@ -37198,7 +37720,8 @@ "node_modules/split-ca": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", - "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" }, "node_modules/split-string": { "version": "3.1.0", @@ -37233,6 +37756,23 @@ "es5-ext": "^0.10.53" } }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, "node_modules/sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -37347,12 +37887,13 @@ "license": "MIT" }, "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", "dependencies": { - "internal-slot": "^1.0.4" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -37525,15 +38066,18 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -37543,15 +38087,19 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -38040,7 +38588,8 @@ "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", - "deprecated": "Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . Thanks to @shadowgate15, @spence-s, and @niftylettuce. Superagent is sponsored by Forward Email at .", + "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", + "license": "MIT", "dependencies": { "component-emitter": "^1.2.0", "cookiejar": "^2.1.0", @@ -38061,32 +38610,58 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/superagent/node_modules/form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", + "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" }, "engines": { "node": ">= 0.12" } }, + "node_modules/superagent/node_modules/form-data/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/superagent/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" }, "node_modules/superagent/node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -38095,9 +38670,10 @@ } }, "node_modules/superagent/node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -38112,6 +38688,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } @@ -38352,7 +38929,8 @@ "node_modules/swagger-converter": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/swagger-converter/-/swagger-converter-0.1.7.tgz", - "integrity": "sha1-oJdRnG8e5N1n4wjZtT3cnCslf5c=", + "integrity": "sha512-O2hZbWqq8x6j0uZ4qWj5dw45WPoAxKsJLJZqOgTqRtPNi8IqA+rDkDV/48S8qanS3KGv1QcVoPNLivMbyHHdAQ==", + "license": "MIT", "dependencies": { "lodash.clonedeep": "^2.4.1" } @@ -38403,12 +38981,6 @@ "lodash": "^4.17.14" } }, - "node_modules/swagger-tools/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "license": "MIT" - }, "node_modules/swagger-tools/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -38507,9 +39079,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", - "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", + "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -39333,14 +39905,14 @@ } }, "node_modules/traverse": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", - "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", + "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", "license": "MIT", "dependencies": { - "gopd": "^1.0.1", - "typedarray.prototype.slice": "^1.0.3", - "which-typed-array": "^1.1.15" + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" }, "engines": { "node": ">= 0.4" @@ -39497,30 +40069,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -39530,17 +40102,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -39550,17 +40123,17 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" @@ -39575,17 +40148,19 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "node_modules/typedarray.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", - "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", + "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-offset": "^1.0.2" + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -39673,14 +40248,18 @@ } }, "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -41365,30 +41944,64 @@ } }, "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-collection": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", - "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", "dependencies": { - "is-map": "^2.0.1", - "is-set": "^2.0.1", - "is-weakmap": "^2.0.1", - "is-weakset": "^2.0.1" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -41401,15 +42014,17 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { @@ -41926,6 +42541,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-3.25.1.tgz", "integrity": "sha512-7tDlwhrBG+oYFdXNOjILSurpfQyuVgkRe3hB2q8TEssamDHB7BbLWYkYO98nTn0FibfdFroFKDjndbgufAgS/Q==", + "license": "MIT", "dependencies": { "core-js": "^2.5.7", "lodash.get": "^4.0.0", @@ -41939,23 +42555,19 @@ "commander": "^2.7.1" } }, - "node_modules/z-schema/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "optional": true - }, "node_modules/z-schema/node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.", - "hasInstallScript": true + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" }, "node_modules/z-schema/node_modules/validator": { "version": "10.11.0", "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz", "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -42079,13 +42691,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "dockerode": "^4.0.5", + "dockerode": "^4.0.7", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.9", "workerpool": "^6.1.5" }, "devDependencies": { @@ -42152,33 +42764,6 @@ "node": ">= 0.6" } }, - "services/clsi/node_modules/@grpc/grpc-js": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.2.tgz", - "integrity": "sha512-nnR5nmL6lxF8YBqb6gWvEgLdLh/Fn+kvAdX5hUOnt48sNSb0riz/93ASd2E5gvanPA41X6Yp25bIfGRp1SMb2g==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.7.13", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, - "services/clsi/node_modules/cpu-features": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", - "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "buildcheck": "~0.0.6", - "nan": "^2.19.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, "services/clsi/node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -42188,75 +42773,6 @@ "node": ">=0.3.1" } }, - "services/clsi/node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.1", - "readable-stream": "^3.5.0", - "split-ca": "^1.0.1", - "ssh2": "^1.15.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "services/clsi/node_modules/dockerode": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.5.tgz", - "integrity": "sha512-ZPmKSr1k1571Mrh7oIBS/j0AqAccoecY2yH420ni5j1KyNMgnoTh4Nu4FWunh0HZIJmRSmSysJjBIpa/zyWUEA==", - "license": "Apache-2.0", - "dependencies": { - "@balena/dockerignore": "^1.0.2", - "@grpc/grpc-js": "^1.11.1", - "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", - "protobufjs": "^7.3.2", - "tar-fs": "~2.1.2", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "services/clsi/node_modules/dockerode/node_modules/tar-fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", - "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "services/clsi/node_modules/protobufjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", - "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "services/clsi/node_modules/sinon": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.0.3.tgz", @@ -42276,23 +42792,6 @@ "url": "https://opencollective.com/sinon" } }, - "services/clsi/node_modules/ssh2": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", - "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", - "hasInstallScript": true, - "dependencies": { - "asn1": "^0.2.6", - "bcrypt-pbkdf": "^1.0.2" - }, - "engines": { - "node": ">=10.16.0" - }, - "optionalDependencies": { - "cpu-features": "~0.0.10", - "nan": "^2.20.0" - } - }, "services/clsi/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -42305,19 +42804,6 @@ "node": ">=8" } }, - "services/clsi/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "services/contacts": { "name": "@overleaf/contacts", "dependencies": { @@ -44716,7 +45202,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", + "multer": "overleaf/multer#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", @@ -46007,18 +46493,18 @@ } }, "services/web/node_modules/multer": { - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/overleaf/multer.git#199c5ff05bd375c508f4074498237baead7f5148", - "integrity": "sha512-S5MlIoOgrDr+a2jLS8z7jQlbzvZ0m30U2tRwdyLrxhnnMUQZYEzkVysEv10Dw41RTpM5bQQDs563Vzl1LLhxhQ==", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/overleaf/multer.git#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", + "integrity": "sha512-kkvPK48OQibR5vIoTQBbZp1uWVCvT9MrW3Y0mqdhFYJP/HVJujb4eSCEU0yj+hyf0Y+H/BKCmPdM4fJnzqAO4w==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "type-is": "^1.6.18", + "xtend": "^4.0.2" }, "engines": { "node": ">= 10.16.0" diff --git a/package.json b/package.json index 64fbd258ed..a51bbcd743 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "swagger-tools": { "body-parser": "1.20.3", - "multer": "2.0.0", + "multer": "2.0.1", "path-to-regexp": "3.3.0", "qs": "6.13.0" } diff --git a/services/clsi/package.json b/services/clsi/package.json index 86566e0f59..b07430391a 100644 --- a/services/clsi/package.json +++ b/services/clsi/package.json @@ -27,13 +27,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "dockerode": "^4.0.5", + "dockerode": "^4.0.7", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.9", "workerpool": "^6.1.5" }, "devDependencies": { diff --git a/services/web/package.json b/services/web/package.json index 826e051a9d..59825e0e68 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -143,7 +143,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", + "multer": "overleaf/multer#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", From 6d202432ffe5287d2d65b48689c00326401201cc Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 9 Jun 2025 15:28:12 -0400 Subject: [PATCH 075/209] Merge pull request #26209 from overleaf/em-multiple-edit-ops Support multiple ops in the history OT ShareJS type GitOrigin-RevId: fad1e9081ed1978de414c5130692d3b23fcd13d8 --- .../editor/share-js-history-ot-type.ts | 39 ++++- .../unit/share-js-history-ot-type.ts | 134 ++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 0e70e93676..81243bb8c7 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -30,18 +30,49 @@ export const historyOTType = { api, transformX(ops1: EditOperation[], ops2: EditOperation[]) { - const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0]) - return [[a], [b]] + // Dynamic programming algorithm: gradually transform both sides in a nested + // loop. + const left = [...ops1] + const right = [...ops2] + for (let i = 0; i < left.length; i++) { + for (let j = 0; j < right.length; j++) { + // At this point: + // left[0..i] is ops1[0..i] rebased over ops2[0..j-1] + // right[0..j] is ops2[0..j] rebased over ops1[0..i-1] + const [a, b] = EditOperationTransformer.transform(left[i], right[j]) + left[i] = a + right[j] = b + } + } + return [left, right] }, apply(snapshot: StringFileData, ops: EditOperation[]) { const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(ops[0]) + for (const op of ops) { + afterFile.edit(op) + } return afterFile }, compose(ops1: EditOperation[], ops2: EditOperation[]) { - return [ops1[0].compose(ops2[0])] + const ops = [...ops1, ...ops2] + let currentOp = ops.shift() + if (currentOp === undefined) { + // No ops to process + return [] + } + const result = [] + for (const op of ops) { + if (currentOp.canBeComposedWith(op)) { + currentOp = currentOp.compose(op) + } else { + result.push(currentOp) + currentOp = op + } + } + result.push(currentOp) + return result }, // Do not provide normalize, used by submitOp to fixup bad input. diff --git a/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts b/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts new file mode 100644 index 0000000000..8418c59ed0 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai' +import { + StringFileData, + TextOperation, + AddCommentOperation, + Range, +} from 'overleaf-editor-core' +import { historyOTType } from '@/features/ide-react/editor/share-js-history-ot-type' + +describe('historyOTType', function () { + let snapshot: StringFileData + let opsA: TextOperation[] + let opsB: TextOperation[] + + beforeEach(function () { + snapshot = new StringFileData('one plus two equals three') + + // After opsA: "seven plus five equals twelve" + opsA = [new TextOperation(), new TextOperation(), new TextOperation()] + + opsA[0].remove(3) + opsA[0].insert('seven') + opsA[0].retain(22) + + opsA[1].retain(11) + opsA[1].remove(3) + opsA[1].insert('five') + opsA[1].retain(13) + + opsA[2].retain(23) + opsA[2].remove(5) + opsA[2].insert('twelve') + + // After ops2: "one times two equals two" + opsB = [new TextOperation(), new TextOperation()] + + opsB[0].retain(4) + opsB[0].remove(4) + opsB[0].insert('times') + opsB[0].retain(17) + + opsB[1].retain(21) + opsB[1].remove(5) + opsB[1].insert('two') + }) + + describe('apply', function () { + it('supports an empty operations array', function () { + const result = historyOTType.apply(snapshot, []) + expect(result.getContent()).to.equal('one plus two equals three') + }) + + it('applies operations to the snapshot (opsA)', function () { + const result = historyOTType.apply(snapshot, opsA) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('applies operations to the snapshot (opsB)', function () { + const result = historyOTType.apply(snapshot, opsB) + expect(result.getContent()).to.equal('one times two equals two') + }) + }) + + describe('compose', function () { + it('supports empty operations', function () { + const ops = historyOTType.compose([], []) + expect(ops).to.deep.equal([]) + }) + + it('supports an empty operation on the left', function () { + const ops = historyOTType.compose([], opsA) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('supports an empty operation on the right', function () { + const ops = historyOTType.compose(opsA, []) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('supports operations on both sides', function () { + const ops = historyOTType.compose(opsA.slice(0, 2), opsA.slice(2)) + const result = historyOTType.apply(snapshot, ops) + expect(ops.length).to.equal(1) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it("supports operations that can't be composed", function () { + const comment = new AddCommentOperation('comment-id', [new Range(3, 10)]) + const ops = historyOTType.compose(opsA.slice(0, 2), [ + comment, + ...opsA.slice(2), + ]) + expect(ops.length).to.equal(3) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + }) + + describe('transformX', function () { + it('supports empty operations', function () { + const [aPrime, bPrime] = historyOTType.transformX([], []) + expect(aPrime).to.deep.equal([]) + expect(bPrime).to.deep.equal([]) + }) + + it('supports an empty operation on the left', function () { + const [aPrime, bPrime] = historyOTType.transformX([], opsB) + expect(aPrime).to.deep.equal([]) + expect(bPrime).to.deep.equal(opsB) + }) + + it('supports an empty operation on the right', function () { + const [aPrime, bPrime] = historyOTType.transformX(opsA, []) + expect(aPrime).to.deep.equal(opsA) + expect(bPrime).to.deep.equal([]) + }) + + it('supports operations on both sides (a then b)', function () { + const [, bPrime] = historyOTType.transformX(opsA, opsB) + const ops = historyOTType.compose(opsA, bPrime) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven times five equals twelvetwo') + }) + + it('supports operations on both sides (b then a)', function () { + const [aPrime] = historyOTType.transformX(opsA, opsB) + const ops = historyOTType.compose(opsB, aPrime) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven times five equals twelvetwo') + }) + }) +}) From 69e2a57769846927bb555ad65e725c9f53ab43d7 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:14:43 +0300 Subject: [PATCH 076/209] Merge pull request #26141 from overleaf/ii-managed-users-consent-screen [web] Joining managed group from projects page GitOrigin-RevId: 191203559fba94cad45f35de1af2427b2abb9326 --- .../Notifications/NotificationsController.mjs | 22 ++++++ services/web/app/src/router.mjs | 6 ++ .../use-group-invitation-notification.tsx | 76 ++++++++++--------- .../notifications/group-invitation.spec.tsx | 7 ++ .../NotificationsController.test.mjs | 21 +++++ 5 files changed, 98 insertions(+), 34 deletions(-) diff --git a/services/web/app/src/Features/Notifications/NotificationsController.mjs b/services/web/app/src/Features/Notifications/NotificationsController.mjs index ae1d9208f3..35b5f0a677 100644 --- a/services/web/app/src/Features/Notifications/NotificationsController.mjs +++ b/services/web/app/src/Features/Notifications/NotificationsController.mjs @@ -33,4 +33,26 @@ export default { res.sendStatus(200) ) }, + + getNotification(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { notificationId } = req.params + NotificationsHandler.getUserNotifications( + userId, + function (err, unreadNotifications) { + if (err) { + return next(err) + } + const notification = unreadNotifications.find( + n => n._id === notificationId + ) + + if (!notification) { + return res.status(404).end() + } + + res.json(notification) + } + ) + }, } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index a7e8d5e05f..7851a4a66f 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -915,6 +915,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { NotificationsController.markNotificationAsRead ) + webRouter.get( + '/user/notification/:notificationId', + AuthenticationController.requireLogin(), + NotificationsController.getNotification + ) + // Deprecated in favour of /internal/project/:project_id but still used by versioning privateApiRouter.get( '/project/:project_id/details', diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx index 15248f8c42..f62571b722 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx @@ -9,6 +9,7 @@ import type { NotificationGroupInvitation } from '../../../../../../../../../typ import useAsync from '../../../../../../../shared/hooks/use-async' import { FetchError, + getJSON, postJSON, putJSON, } from '../../../../../../../infrastructure/fetch-json' @@ -43,17 +44,12 @@ type UseGroupInvitationNotificationReturnType = { export function useGroupInvitationNotification( notification: NotificationGroupInvitation ): UseGroupInvitationNotificationReturnType { - const { - _id: notificationId, - messageOpts: { token, managedUsersEnabled }, - } = notification - + const { _id: notificationId } = notification const [groupInvitationStatus, setGroupInvitationStatus] = useState(GroupInvitationStatus.Idle) - const { runAsync, isLoading: isAcceptingInvitation } = useAsync< - never, - FetchError - >() + const { runAsync, isLoading } = useAsync() + const { runAsync: runAsyncNotification, isLoading: isLoadingNotification } = + useAsync() const location = useLocation() const { handleDismiss } = useAsyncDismiss() @@ -72,31 +68,41 @@ export function useGroupInvitationNotification( }, [hasIndividualPaidSubscription]) const acceptGroupInvite = useCallback(() => { - if (managedUsersEnabled) { - location.assign(`/subscription/invites/${token}/`) - } else { - runAsync( - putJSON(`/subscription/invites/${token}/`, { - body: { - _csrf: getMeta('ol-csrfToken'), - }, - }) - ) - .then(() => { - setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined) - }) - .catch(err => { - debugConsole.error(err) - setGroupInvitationStatus(GroupInvitationStatus.Error) - }) - .finally(() => { - // remove notification automatically in the browser - window.setTimeout(() => { - setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden) - }, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN) - }) - } - }, [runAsync, token, location, managedUsersEnabled]) + // Fetch the latest notification data to ensure it's up-to-date + runAsyncNotification(getJSON(`/user/notification/${notificationId}`)) + .then(notification => { + const { + messageOpts: { token, managedUsersEnabled }, + } = notification + if (managedUsersEnabled) { + location.assign(`/subscription/invites/${token}/`) + } else { + runAsync( + putJSON(`/subscription/invites/${token}/`, { + body: { + _csrf: getMeta('ol-csrfToken'), + }, + }) + ) + .then(() => { + setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined) + }) + .catch(err => { + debugConsole.error(err) + setGroupInvitationStatus(GroupInvitationStatus.Error) + }) + .finally(() => { + // remove notification automatically in the browser + window.setTimeout(() => { + setGroupInvitationStatus( + GroupInvitationStatus.NotificationIsHidden + ) + }, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN) + }) + } + }) + .catch(debugConsole.error) + }, [runAsync, runAsyncNotification, notificationId, location]) const cancelPersonalSubscription = useCallback(() => { setGroupInvitationStatus(GroupInvitationStatus.AskToJoin) @@ -114,6 +120,8 @@ export function useGroupInvitationNotification( setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden) }, []) + const isAcceptingInvitation = isLoadingNotification || isLoading + return { isAcceptingInvitation, groupInvitationStatus, diff --git a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx index 31114a2405..c29de58f98 100644 --- a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx +++ b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx @@ -27,6 +27,10 @@ describe('', function () { } beforeEach(function () { + cy.intercept('GET', `/user/notification/${notification._id}`, { + statusCode: 200, + body: notification, + }).as('getNotification') cy.intercept( 'PUT', `/subscription/invites/${notification.messageOpts.token}`, @@ -48,6 +52,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( @@ -82,6 +87,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( @@ -116,6 +122,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( diff --git a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs index 6e1f9177c0..1bc5c51b31 100644 --- a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs @@ -14,6 +14,9 @@ describe('NotificationsController', function () { ctx.handler = { getUserNotifications: sinon.stub().callsArgWith(1), markAsRead: sinon.stub().callsArgWith(2), + promises: { + getUserNotifications: sinon.stub().callsArgWith(1), + }, } ctx.req = { params: { @@ -77,4 +80,22 @@ describe('NotificationsController', function () { }) }) }) + + it('should get a notification by notification id', function (ctx) { + return new Promise(resolve => { + const notification = { _id: notificationId, user_id: userId } + ctx.handler.getUserNotifications = sinon + .stub() + .callsArgWith(1, null, [notification]) + ctx.controller.getNotification(ctx.req, { + json: body => { + body.should.deep.equal(notification) + resolve() + }, + status: () => ({ + end: () => {}, + }), + }) + }) + }) }) From 312664bd2da18d84cf15fab327d003c2e67b062e Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Tue, 10 Jun 2025 09:14:52 +0100 Subject: [PATCH 077/209] Merge pull request #26265 from overleaf/ds-cms-bs5-customer-stories-2 [B2C] Bootstrap 5 migration of Customer stories page GitOrigin-RevId: cca0d00412ab4ec5da15e26e4e7eb3c40de9e47c --- .../bootstrap-5/pages/website-redesign.scss | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 1f6027d835..80de22a186 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -551,6 +551,20 @@ } } + .customer-stories-hero-heading { + @include media-breakpoint-down(lg) { + @include heading-xl; + } + } + + .customer-stories-logos-text { + font-size: var(--font-size-05); + } + + .customer-stories-hero-text { + font-size: var(--font-size-05); + } + .overleaf-sticker { width: unset; From 3da4dc71f1fc8470ce6c6535dbe13db697edff27 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 13:40:52 +0100 Subject: [PATCH 078/209] Modify no-unused-vars behaviour using @typescript-eslint/no-unused-vars reduces the number of false positives in TS code. The changes: 1. Allow the arguments to a function to be checked (reporting only after the last used variable) 2. Allow rest siblings to be checked 3. Allow these rules to be skipped with an _ prefix to a variable GitOrigin-RevId: 1f6eac4109859415218248d5b2068a22b34cfd7e --- services/web/.eslintrc.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index 2fa9e8f547..ef3cf11de5 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -383,6 +383,18 @@ module.exports = { 'Modify location via customLocalStorage instead of calling window.localStorage methods directly', }, ], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: false, + caughtErrors: 'none', + vars: 'all', + varsIgnorePattern: '^_', + }, + ], }, }, { From 542008c61df61693e5933994d9b3dc566464ac7d Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 14:48:21 +0100 Subject: [PATCH 079/209] Remove unused event arguments GitOrigin-RevId: 25858d07865d6b9a7caa4997d031586a248d8e8b --- services/web/frontend/js/features/contact-form/index.js | 2 +- .../components/table-generator/toolbar/toolbar-button-menu.tsx | 2 +- .../features/source-editor/components/toolbar/math-dropdown.tsx | 2 +- .../source-editor/components/toolbar/table-dropdown.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/contact-form/index.js b/services/web/frontend/js/features/contact-form/index.js index 0b4a4898aa..51aff806e3 100644 --- a/services/web/frontend/js/features/contact-form/index.js +++ b/services/web/frontend/js/features/contact-form/index.js @@ -23,7 +23,7 @@ document }) document.querySelectorAll('[data-ol-contact-form]').forEach(el => { - el.addEventListener('submit', function (e) { + el.addEventListener('submit', function () { const emailValue = document.querySelector( '[data-ol-contact-form-email-input]' ).value diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx index 51c68872f6..d63ed7b706 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx @@ -36,7 +36,7 @@ export const ToolbarButtonMenu: FC< event.preventDefault() event.stopPropagation() }} - onClick={event => { + onClick={() => { onToggle(!open) }} disabled={disabled} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx index b34a61c69d..748a04d7cb 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx @@ -34,7 +34,7 @@ export const MathDropdown = memo(function MathDropdown() { { + onClick={() => { writefullInstance?.openEquationGenerator() }} > diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx index 190d2e7c7d..a191b63600 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx @@ -46,7 +46,7 @@ export const TableDropdown = memo(function TableDropdown() { { + onClick={() => { writefullInstance?.openTableGenerator() }} > From eb60d364f62a7477c74fe6f47ff2f4036651023c Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 15:18:49 +0100 Subject: [PATCH 080/209] Fix instances of ...rest filtering GitOrigin-RevId: 9f2889b08ffed20466d7022a5aba69d3e87c5ed9 --- .../js/features/review-panel-new/context/threads-context.tsx | 2 +- .../ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx | 2 +- services/web/frontend/stories/contact-us-modal.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index d5cf34ef93..48c44feed7 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -89,7 +89,7 @@ export const ThreadsProvider: FC = ({ children }) => { ) => { setData(value => { if (value) { - const { submitting, ...thread } = value[threadId] ?? { + const { submitting: _1, ...thread } = value[threadId] ?? { messages: [], } diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx index cdf20e3dd3..719b936581 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx @@ -29,7 +29,7 @@ const DropdownToggleWithTooltip = forwardRef< toolTipDescription, overlayTriggerProps, tooltipProps, - id, + id: _id, ...toggleProps }, ref diff --git a/services/web/frontend/stories/contact-us-modal.stories.tsx b/services/web/frontend/stories/contact-us-modal.stories.tsx index b1c0e2c431..24c671ce10 100644 --- a/services/web/frontend/stories/contact-us-modal.stories.tsx +++ b/services/web/frontend/stories/contact-us-modal.stories.tsx @@ -69,7 +69,7 @@ const ContactUsModalWithAcknowledgement = ( } export const WithAcknowledgement = (args: ContactUsModalProps) => { - const { show, handleHide, ...rest } = args + const { show: _show, handleHide: _handleHide, ...rest } = args return } From 496056964862967d79c18fbcd4d5e523ae8590de Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 15:21:01 +0100 Subject: [PATCH 081/209] Remove unused full arguments As distinct from removing destructured props. GitOrigin-RevId: d02ad8d36fb532559ed2899268d7b699f2f2fa37 --- .../dropdown/history-dropdown-content.tsx | 6 +----- .../dropdown/menu-item/add-label.tsx | 10 ++-------- .../features/history/extensions/highlights.ts | 6 +++--- .../ide-react/editor/document-container.ts | 4 ++-- .../project-tools/buttons/tags-dropdown.tsx | 2 +- .../components/review-panel.tsx | 2 +- .../hooks/use-review-panel-styles.ts | 2 +- .../table-generator/toolbar/commands.ts | 6 +++--- .../extensions/cursor-highlights.ts | 2 +- .../source-editor/extensions/cursor-position.ts | 2 +- .../source-editor/extensions/draw-selection.ts | 4 ++-- .../extensions/empty-line-filler.ts | 4 ++-- .../source-editor/extensions/keybindings.ts | 17 +++++++---------- .../features/source-editor/extensions/ranges.ts | 2 +- .../source-editor/extensions/realtime.ts | 5 +---- .../extensions/vertical-overflow.ts | 2 +- .../extensions/visual/visual-widgets/end.ts | 2 +- .../visual/visual-widgets/environment-line.ts | 4 ++-- .../languages/latex/latex-indent-service.ts | 2 +- .../change-plan/individual-plans-table.tsx | 4 ++-- .../web/frontend/stories/decorators/scope.tsx | 2 +- .../web/frontend/stories/fixtures/compile.js | 4 ++-- .../stories/split-test-badge.stories.jsx | 2 +- .../stories/ui/dropdown-menu.stories.tsx | 4 ++-- .../stories/ui/split-button.stories.tsx | 4 +--- .../dictionary-modal-content.spec.jsx | 8 ++++---- .../components/emails/add-email-input.test.tsx | 2 +- .../frontend/ide/log-parser/logParserTests.js | 2 +- 28 files changed, 49 insertions(+), 67 deletions(-) diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx index ac7a0044d8..43858f7eb3 100644 --- a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx +++ b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx @@ -28,11 +28,7 @@ function HistoryDropdownContent({ return ( <> {permissions.labelVersion && ( - + )} void } -function AddLabel({ - version, - projectId, - closeDropdown, - ...props -}: DownloadProps) { +function AddLabel({ version, closeDropdown, ...props }: AddLabelProps) { const { t } = useTranslation() const [showModal, setShowModal] = useState(false) diff --git a/services/web/frontend/js/features/history/extensions/highlights.ts b/services/web/frontend/js/features/history/extensions/highlights.ts index ce274cf724..1f81f82e74 100644 --- a/services/web/frontend/js/features/history/extensions/highlights.ts +++ b/services/web/frontend/js/features/history/extensions/highlights.ts @@ -238,7 +238,7 @@ class EmptyLineAdditionMarkerWidget extends WidgetType { super() } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.classList.add( 'ol-cm-empty-line-addition-marker', @@ -255,7 +255,7 @@ class EmptyLineDeletionMarkerWidget extends WidgetType { super() } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.classList.add( 'ol-cm-empty-line-deletion-marker', @@ -297,7 +297,7 @@ class ChangeGutterMarker extends GutterMarker { super() } - toDOM(view: EditorView) { + toDOM() { const el = document.createElement('div') el.className = 'ol-cm-changed-line-gutter' el.style.setProperty('--hue', this.hue.toString()) diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index 2ded041fb1..28bcb955d1 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -599,7 +599,7 @@ export class DocumentContainer extends EventEmitter { this.doc.on('remoteop', (...ops: AnyOperation[]) => { return this.trigger('remoteop', ...ops) }) - this.doc.on('op:sent', (op: AnyOperation) => { + this.doc.on('op:sent', () => { return this.trigger('op:sent') }) this.doc.on('op:acknowledged', (op: AnyOperation) => { @@ -609,7 +609,7 @@ export class DocumentContainer extends EventEmitter { }) return this.trigger('op:acknowledged') }) - this.doc.on('op:timeout', (op: AnyOperation) => { + this.doc.on('op:timeout', () => { this.trigger('op:timeout') return this.onError(new Error('op timed out')) }) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx index 443962cc3c..06dd9b8ff3 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx @@ -91,7 +91,7 @@ function TagsDropdown() { data-testid="project-tools-more-dropdown-menu" > {t('add_to_tag')} - {sortBy(tags, tag => tag.name?.toLowerCase()).map((tag, index) => ( + {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => (
  • diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx index 7d8b694f68..74405ba276 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx @@ -17,7 +17,7 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => { [choosenSubView, mini] ) - const style = useReviewPanelStyles(mini) + const style = useReviewPanelStyles() const className = classnames('review-panel-container', { 'review-panel-mini': mini, diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts index 7e7dda1850..727701ccc3 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts @@ -1,7 +1,7 @@ import { CSSProperties, useCallback, useEffect, useState } from 'react' import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context' -export const useReviewPanelStyles = (mini: boolean) => { +export const useReviewPanelStyles = () => { const view = useCodeMirrorViewContext() const [styles, setStyles] = useState({ diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index 2645e853bd..ab58179586 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -45,16 +45,16 @@ const themeGenerators: Record = { left: true, right: number === numColumns - 1, }), - row: (number: number, numRows: number) => '\\hline', + row: () => '\\hline', multicolumn: () => ({ left: true, right: true }), lastRow: () => '\\hline', }, [BorderTheme.BOOKTABS]: { - column: (number: number, numColumns: number) => ({ + column: () => ({ left: false, right: false, }), - row: (number: number, numRows: number) => { + row: (number: number) => { if (number === 0) { return '\\toprule' } diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts index 78d2903825..ccdc8b90e7 100644 --- a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts @@ -187,7 +187,7 @@ class CursorMarker extends RectangleMarker { const cursorHighlightsLayer = layer({ above: true, class: 'ol-cm-cursorHighlightsLayer', - update: (update, layer) => { + update: update => { return ( update.docChanged || update.selectionSet || diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts index efde64f40e..0cd69d8b1f 100644 --- a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts @@ -42,7 +42,7 @@ export const cursorPosition = ({ // Asynchronously dispatch cursor position when the selection changes and // provide a little debouncing. Using requestAnimationFrame postpones it // until the next CM6 DOM update. - ViewPlugin.define(view => { + ViewPlugin.define(() => { let animationFrameRequest: number | null = null return { diff --git a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts index af31353a23..413317ec0a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts +++ b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts @@ -71,7 +71,7 @@ const cursorLayer = layer({ updateHasMouseDownEffect(update) ) }, - mount(dom, view) { + mount(dom) { dom.style.animationDuration = '1200ms' }, class: 'cm-cursorLayer', @@ -90,7 +90,7 @@ const selectionLayer = layer({ } return markers }, - update(update, dom) { + update(update) { return ( update.docChanged || update.selectionSet || diff --git a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts index 647463d608..49d9b195b9 100644 --- a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts +++ b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts @@ -9,13 +9,13 @@ import { import browser from './browser' class EmptyLineWidget extends WidgetType { - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.className = 'ol-cm-filler' return element } - eq(widget: EmptyLineWidget) { + eq() { return true } } diff --git a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts index 3e67b4b753..01c39d67ba 100644 --- a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts +++ b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts @@ -34,17 +34,14 @@ const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => { // Allow copy via Ctrl-C in insert mode _Vim.unmap('', 'insert') - _Vim.defineAction( - 'insertModeCtrlC', - (cm: CodeMirror, actionArgs: object, state: any) => { - if (hasNonEmptySelection(cm)) { - navigator.clipboard.writeText(cm.getSelection()) - cm.setSelection(cm.getCursor(), cm.getCursor()) - } else { - _Vim.exitInsertMode(cm) - } + _Vim.defineAction('insertModeCtrlC', (cm: CodeMirror) => { + if (hasNonEmptySelection(cm)) { + navigator.clipboard.writeText(cm.getSelection()) + cm.setSelection(cm.getCursor(), cm.getCursor()) + } else { + _Vim.exitInsertMode(cm) } - ) + }) // Overwrite the moveByCharacters command with a decoration-aware version _Vim.defineMotion( diff --git a/services/web/frontend/js/features/source-editor/extensions/ranges.ts b/services/web/frontend/js/features/source-editor/extensions/ranges.ts index 8dc4489d57..7bde7a4adb 100644 --- a/services/web/frontend/js/features/source-editor/extensions/ranges.ts +++ b/services/web/frontend/js/features/source-editor/extensions/ranges.ts @@ -68,7 +68,7 @@ export const rangesDataField = StateField.define({ export const ranges = () => [ rangesDataField, // handle viewportChanged updates - ViewPlugin.define(view => { + ViewPlugin.define(() => { let timer: number return { diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index e9f5710338..58cfa8712a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -334,10 +334,7 @@ class HistoryOTAdapter { } } - handleUpdateFromCM( - transactions: readonly Transaction[], - ranges?: RangesTracker - ) { + handleUpdateFromCM(transactions: readonly Transaction[]) { for (const transaction of transactions) { if ( this.maxDocLength && diff --git a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts index 20505ed95d..873343c2bc 100644 --- a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts +++ b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts @@ -188,7 +188,7 @@ class TopPaddingWidget extends WidgetType { this.height = height } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('div') element.style.height = this.height + 'px' return element diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts index 232399de3b..3ca2439ae1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts @@ -7,7 +7,7 @@ export class EndWidget extends WidgetType { return element } - eq(widget: EndWidget) { + eq() { return true } diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts index d6ab42503e..d506ac2c38 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts @@ -1,4 +1,4 @@ -import { EditorView, WidgetType } from '@codemirror/view' +import { WidgetType } from '@codemirror/view' export class EnvironmentLineWidget extends WidgetType { constructor( @@ -8,7 +8,7 @@ export class EnvironmentLineWidget extends WidgetType { super() } - toDOM(view: EditorView) { + toDOM() { const element = document.createElement('div') element.classList.add(`ol-cm-environment-${this.environment}`) element.classList.add('ol-cm-environment-edge') diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts index 08c1798032..d1e8e84bc4 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts @@ -1,7 +1,7 @@ import { indentService } from '@codemirror/language' export const latexIndentService = () => - indentService.of((indentContext, pos) => { + indentService.of(indentContext => { // only use this for insertNewLineAndIndent if (indentContext.simulatedBreak) { // match the indentation of the previous line (if present) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx index a6ede01715..d8c98fc56b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx @@ -20,7 +20,7 @@ function ChangeToPlanButton({ planCode }: { planCode: string }) { ) } -function KeepCurrentPlanButton({ plan }: { plan: Plan }) { +function KeepCurrentPlanButton() { const { t } = useTranslation() const { handleOpenModal } = useSubscriptionDashboardContext() @@ -43,7 +43,7 @@ function ChangePlanButton({ plan }: { plan: Plan }) { plan.planCode === personalSubscription.planCode.split('_')[0] if (isCurrentPlanForUser && personalSubscription.pendingPlan) { - return + return } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) { return ( diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index e69ebd8d21..ae6e366eb8 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -72,7 +72,7 @@ const initialize = () => { } }, 0) }, - $on: (eventName: string, callback: () => void) => { + $on: () => { // }, $broadcast: () => {}, diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js index 9471ff04ff..bc7ebfae8b 100644 --- a/services/web/frontend/stories/fixtures/compile.js +++ b/services/web/frontend/stories/fixtures/compile.js @@ -100,7 +100,7 @@ export const mockClearCache = fetchMock => }) export const mockBuildFile = fetchMock => - fetchMock.get('express:/build/:file', (url, options, request) => { + fetchMock.get('express:/build/:file', url => { const { pathname } = new URL(url, 'https://example.com') switch (pathname) { @@ -190,7 +190,7 @@ export const mockEventTracking = fetchMock => fetchMock.get('express:/event/:event', 204) export const mockValidPdf = fetchMock => - fetchMock.get('express:/build/output.pdf', (url, options, request) => { + fetchMock.get('express:/build/output.pdf', () => { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.addEventListener('load', () => { diff --git a/services/web/frontend/stories/split-test-badge.stories.jsx b/services/web/frontend/stories/split-test-badge.stories.jsx index ecb74b71d1..331263e0cb 100644 --- a/services/web/frontend/stories/split-test-badge.stories.jsx +++ b/services/web/frontend/stories/split-test-badge.stories.jsx @@ -127,7 +127,7 @@ export default { displayOnVariants: ['active'], }, decorators: [ - (Story, context) => ( + Story => ( diff --git a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx index 5d1ac376bb..5758640ebc 100644 --- a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx +++ b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx @@ -60,7 +60,7 @@ export const Active = (args: Args) => { ) } -export const MultipleSelection = (args: Args) => { +export const MultipleSelection = () => { return ( Header @@ -191,7 +191,7 @@ export const LeadingIcon = (args: Args) => { ) } -export const TrailingIcon = (args: Args) => { +export const TrailingIcon = () => { return ( diff --git a/services/web/frontend/stories/ui/split-button.stories.tsx b/services/web/frontend/stories/ui/split-button.stories.tsx index 674d2e796b..01cbab9ea4 100644 --- a/services/web/frontend/stories/ui/split-button.stories.tsx +++ b/services/web/frontend/stories/ui/split-button.stories.tsx @@ -11,9 +11,7 @@ import { import Button from '@/features/ui/components/bootstrap-5/button' import { ButtonGroup } from 'react-bootstrap' -type Args = React.ComponentProps - -export const Sizes = (args: Args) => { +export const Sizes = () => { const { t } = useTranslation() const sizes = { Large: 'lg', 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 c28eef66ef..c8cdd931b3 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 @@ -19,7 +19,7 @@ describe('', function () { }) it('list words', function () { - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['foo', 'bar']) }) @@ -34,7 +34,7 @@ describe('', function () { }) it('shows message when empty', function () { - cy.then(win => { + cy.then(() => { learnedWords.global = new Set([]) }) @@ -50,7 +50,7 @@ describe('', function () { it('removes words', function () { cy.intercept('/spelling/unlearn', { statusCode: 200 }) - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['Foo', 'bar']) }) @@ -76,7 +76,7 @@ describe('', function () { it('handles errors', function () { cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn') - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['foo']) }) diff --git a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx index 50220152c6..694a13f32c 100644 --- a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx @@ -13,7 +13,7 @@ const testInstitutionData = [ describe('', function () { const defaultProps = { - onChange: (value: string) => {}, + onChange: () => {}, handleAddNewEmail: () => {}, } diff --git a/services/web/test/frontend/ide/log-parser/logParserTests.js b/services/web/test/frontend/ide/log-parser/logParserTests.js index 098ee056b9..59cdd5d22e 100644 --- a/services/web/test/frontend/ide/log-parser/logParserTests.js +++ b/services/web/test/frontend/ide/log-parser/logParserTests.js @@ -6,7 +6,7 @@ const fixturePath = '../../helpers/fixtures/logs/' const fs = require('fs') const path = require('path') -describe('logParser', function (done) { +describe('logParser', function () { it('should parse errors', function () { const { errors } = parseLatexLog('errors.log', { ignoreDuplicates: true }) expect(errors.map(e => [e.line, e.message])).to.deep.equal([ From c1f5d7c40c62a5c8cc34c8dcd4615744404d3933 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 4 Oct 2024 12:04:56 +0100 Subject: [PATCH 082/209] Ignore params that are needed for type integrity These params are either used in a descendent or ancestor of the relevant file and form part of the interface of the method even if they are not directly used. GitOrigin-RevId: 8bf64cecc69a9ae9e6c50797de5ce8db86757440 --- .../source-editor/extensions/visual/visual-widgets/begin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts index 70e508d93e..1826b48719 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts @@ -45,6 +45,7 @@ export class BeginWidget extends WidgetType { return element.getBoundingClientRect() } + // eslint-disable-next-line @typescript-eslint/no-unused-vars buildName(name: HTMLSpanElement, view: EditorView) { name.textContent = this.environment } From 25675ce2ba8210032c867eba5cfd66314c6931d7 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 16:08:05 +0100 Subject: [PATCH 083/209] Remove unused params from destructuring GitOrigin-RevId: e47a16e2d99e923c314fd0fa2220c19b7b2c9b51 --- .../members-table/dropdown-button.tsx | 1 - ...imeout-message-after-paywall-dismissal.tsx | 30 ++----------------- .../features/pdf-preview/util/pdf-caching.js | 1 - .../components/dropdown/sort-by-dropdown.tsx | 2 +- .../project-list/components/tags-list.tsx | 3 +- .../settings/components/linking-section.tsx | 4 +-- .../components/select-collaborators.tsx | 2 +- 7 files changed, 8 insertions(+), 35 deletions(-) diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index bd3b5ee10e..b62e9ce391 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -291,7 +291,6 @@ type MenuItemButtonProps = { function MenuItemButton({ children, onClick, - className, isLoading, variant, 'data-testid': dataTestId, diff --git a/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx b/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx index db6140085f..3a1d66fd3d 100644 --- a/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx @@ -1,40 +1,20 @@ import getMeta from '@/utils/meta' import { Trans, useTranslation } from 'react-i18next' -import { memo, useCallback, useEffect } from 'react' +import { memo, useEffect } from 'react' import { useDetachCompileContext } from '@/shared/context/detach-compile-context' import StartFreeTrialButton from '@/shared/components/start-free-trial-button' import MaterialIcon from '@/shared/components/material-icon' -import { useStopOnFirstError } from '@/shared/hooks/use-stop-on-first-error' import * as eventTracking from '@/infrastructure/event-tracking' import PdfLogEntry from './pdf-log-entry' function TimeoutMessageAfterPaywallDismissal() { - const { - startCompile, - lastCompileOptions, - setAnimateCompileDropdownArrow, - isProjectOwner, - } = useDetachCompileContext() - - const { enableStopOnFirstError } = useStopOnFirstError({ - eventSource: 'timeout-new', - }) - - const handleEnableStopOnFirstErrorClick = useCallback(() => { - enableStopOnFirstError() - startCompile({ stopOnFirstError: true }) - setAnimateCompileDropdownArrow(true) - }, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow]) + const { lastCompileOptions, isProjectOwner } = useDetachCompileContext() return (
    {getMeta('ol-ExposedSettings').enableSubscriptions && ( - + )}
    ) @@ -124,14 +104,10 @@ const CompileTimeout = memo(function CompileTimeout({ type PreventTimeoutHelpMessageProps = { lastCompileOptions: any - handleEnableStopOnFirstErrorClick: () => void - isProjectOwner: boolean } const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({ lastCompileOptions, - handleEnableStopOnFirstErrorClick, - isProjectOwner, }: PreventTimeoutHelpMessageProps) { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js b/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js index 7fd17c87bf..c3dba41d8b 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js @@ -247,7 +247,6 @@ function usageAboveThreshold(chunk) { function cutRequestAmplification({ potentialChunks, usageScore, - cachedUrls, metrics, start, end, diff --git a/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx index 0d23aebf57..db92561728 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx @@ -12,7 +12,7 @@ import { DropdownToggle, } from '@/features/ui/components/bootstrap-5/dropdown-menu' -function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) { +function Item({ onClick, text, iconType }: SortBtnProps) { return ( void - onEditClick?: () => void } -function TagsList({ onTagClick, onEditClick }: TagsListProps) { +function TagsList({ onTagClick }: TagsListProps) { const { t } = useTranslation() const { tags, untaggedProjectsCount, selectedTagId, selectTag } = useProjectListContext() diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index 0b9001927e..a198cb1328 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -115,7 +115,7 @@ function LinkingSection() { ) : null}
    {allIntegrationLinkingWidgets.map( - ({ import: importObject, path }, widgetIndex) => ( + ({ import: importObject }, widgetIndex) => ( {t('reference_managers')}
    {referenceLinkingWidgets.map( - ({ import: importObject, path }, widgetIndex) => ( + ({ import: importObject }, widgetIndex) => ( (item && item.name) || '', stateReducer, - onStateChange: ({ inputValue, type, selectedItem }) => { + onStateChange: ({ type, selectedItem }) => { switch (type) { // add a selected item on Enter (keypress), click or blur case useCombobox.stateChangeTypes.InputKeyDownEnter: From f87113077332234615bb707a05f9c5cfbbb1f07b Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 16:08:47 +0100 Subject: [PATCH 084/209] Disable lint warnings for stubbed class GitOrigin-RevId: bcee2d1ea4fcb5543fa31fd2174641e55d6c4d39 --- .../js/features/ide-react/context/snapshot-context.tsx | 6 +++++- .../languages/latex/linter/latex-linter.worker.js | 3 +++ services/web/frontend/js/ide/connection/SocketIoShim.js | 8 ++++++++ services/web/test/frontend/bootstrap.js | 4 ++++ .../frontend/features/source-editor/helpers/mock-doc.ts | 1 + 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx index 70f170a8b0..817e03fe86 100644 --- a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx @@ -24,10 +24,14 @@ export const StubSnapshotUtils = { throw new Error('not implemented') } }, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars buildFileTree(snapshot: Snapshot): Folder { throw new Error('not implemented') }, - createFolder(_id: string, name: string): Folder { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createFolder(id: string, name: string): Folder { throw new Error('not implemented') }, } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js index 0bfaf94d62..c496ce767f 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js @@ -2087,7 +2087,10 @@ if (typeof onmessage !== 'undefined') { } // export dummy class for testing export default class LintWorker { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars postMessage(message) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars addEventListener(eventName, listener) {} Parse(text) { return Parse(text) diff --git a/services/web/frontend/js/ide/connection/SocketIoShim.js b/services/web/frontend/js/ide/connection/SocketIoShim.js index 9fb57ef1f1..6d9effd442 100644 --- a/services/web/frontend/js/ide/connection/SocketIoShim.js +++ b/services/web/frontend/js/ide/connection/SocketIoShim.js @@ -4,6 +4,8 @@ import { debugConsole } from '@/utils/debugging' import EventEmitter from '@/utils/EventEmitter' class SocketShimBase { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars static connect(url, options) { return new SocketShimBase() } @@ -46,11 +48,15 @@ class SocketShimNoop extends SocketShimBase { }, connect() {}, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {}, } } connect() {} + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {} emit() {} on() {} @@ -295,6 +301,8 @@ export class SocketIOMock extends SocketShimBase { }, connect() {}, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {}, } } diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js index df4d3f1464..496b7b588d 100644 --- a/services/web/test/frontend/bootstrap.js +++ b/services/web/test/frontend/bootstrap.js @@ -65,8 +65,12 @@ globalThis.BroadcastChannel = global.BroadcastChannel = window.BroadcastChannel = class BroadcastChannel { + // Unused arguments left to document the signature of the stubbed function. + // eslint-disable-next-line @typescript-eslint/no-unused-vars addEventListener(type, listener) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars removeEventListener(type, listener) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars postMessage(message) {} } diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index f13d9ad6bb..a4944c1e97 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -106,6 +106,7 @@ export const mockDoc = ( removeCommentId: () => {}, ...rangesOptions, }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, From 52280febf6ab0b3553727862891e983026d99314 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 27 Sep 2024 12:13:32 +0100 Subject: [PATCH 085/209] When filtering object members from rest use full name GitOrigin-RevId: 0c21c70b2512931744f18e79c8d9e4bb85e83dfa --- .../js/features/review-panel-new/context/threads-context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index 48c44feed7..0a5c737585 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -89,7 +89,7 @@ export const ThreadsProvider: FC = ({ children }) => { ) => { setData(value => { if (value) { - const { submitting: _1, ...thread } = value[threadId] ?? { + const { submitting: _submitting, ...thread } = value[threadId] ?? { messages: [], } From 2c07fa1f778829f8c1683ec6d9d77639abbaa712 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 30 May 2025 15:53:00 +0100 Subject: [PATCH 086/209] Skip unused array members GitOrigin-RevId: 5ea4dd880505e65fe7545e0c0d4301236ad103e7 --- .../share-project-modal/components/share-project-modal.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx index 88f3482c4b..6c43e548ea 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx @@ -617,7 +617,7 @@ describe('', function () { fetchMock.post( 'express:/project/:projectId/invite', - ({ args: [url, req] }) => { + ({ args: [, req] }) => { const data = JSON.parse(req.body) if (data.email === 'a@b.c') { From ce3054713fce070cf369a0aacf313b14d4943a91 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 13:53:38 +0100 Subject: [PATCH 087/209] Remove unused variable GitOrigin-RevId: 57b864aff3317513f981b101feafac28d3379403 --- services/web/frontend/js/features/tooltip/index-bs5.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/tooltip/index-bs5.ts b/services/web/frontend/js/features/tooltip/index-bs5.ts index 62c199e2e6..43d6bc015f 100644 --- a/services/web/frontend/js/features/tooltip/index-bs5.ts +++ b/services/web/frontend/js/features/tooltip/index-bs5.ts @@ -21,8 +21,8 @@ if (footerLanguageElement) { const allTooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]') allTooltips.forEach(element => { - // eslint-disable-next-line no-unused-vars - const tooltip = new Tooltip(element) + // eslint-disable-next-line no-new + new Tooltip(element) }) const possibleBadgeTooltips = document.querySelectorAll('[data-badge-tooltip]') @@ -36,8 +36,8 @@ possibleBadgeTooltips.forEach(element => { if (element.parentElement) { const parentWidth = getElementWidth(element.parentElement) if (element.scrollWidth > parentWidth) { - // eslint-disable-next-line no-unused-vars - const tooltip = new Tooltip(element) + // eslint-disable-next-line no-new + new Tooltip(element) } else { element.parentElement.style.maxWidth = 'none' } From 637312e4f8ee99e6337829dab4be8be9113ef4dc Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:03:06 +0100 Subject: [PATCH 088/209] Merge pull request #26135 from overleaf/dp-error-logs-ai Add AI paywall to new error logs GitOrigin-RevId: 2d6dad11dfe3b27c8ff322a9778a53496cfe7277 --- services/web/config/settings.defaults.js | 1 + services/web/frontend/extracted-translations.json | 3 +++ .../ide-redesign/components/error-logs/error-logs.tsx | 11 ++++++++++- services/web/locales/en.json | 3 +++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 4d55f21db8..43544814fd 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1002,6 +1002,7 @@ module.exports = { fullProjectSearchPanel: [], integrationPanelComponents: [], referenceSearchSetting: [], + errorLogsComponents: [], }, moduleImportSequence: [ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index fda4b6368b..6b730db1a1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1,7 +1,9 @@ { + "0_free_suggestions": "", "12x_more_compile_time": "", "1_2_width": "", "1_4_width": "", + "1_free_suggestion": "", "3_4_width": "", "About": "", "Account": "", @@ -624,6 +626,7 @@ "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", "generic_something_went_wrong": "", + "get_ai_assist": "", "get_collaborative_benefits": "", "get_discounted_plan": "", "get_error_assist": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx index 7b54785295..64a96d677d 100644 --- a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { memo, useMemo, useState } from 'react' +import { ElementType, memo, useMemo, useState } from 'react' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import StopOnFirstErrorPrompt from '@/features/pdf-preview/components/stop-on-first-error-prompt' import PdfPreviewError from '@/features/pdf-preview/components/pdf-preview-error' @@ -11,6 +11,12 @@ import { useDetachCompileContext as useCompileContext } from '@/shared/context/d import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap' import { LogEntry as LogEntryData } from '@/features/pdf-preview/util/types' import LogEntry from './log-entry' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' + +const logsComponents: Array<{ + import: { default: ElementType } + path: string +}> = importOverleafModules('errorLogsComponents') type ErrorLogTab = { key: string @@ -52,6 +58,9 @@ function ErrorLogs() { ))} + {logsComponents.map(({ import: { default: Component }, path }) => ( + + ))}
    {stoppedOnFirstError && includeErrors && } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 78ea2d6463..837e4ea09a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1,8 +1,10 @@ { + "0_free_suggestions": "0 free suggestions", "12x_basic": "12x Basic", "12x_more_compile_time": "12x more compile time on our fastest servers", "1_2_width": "½ width", "1_4_width": "¼ width", + "1_free_suggestion": "1 free suggestion", "3_4_width": "¾ width", "About": "About", "Account": "Account", @@ -824,6 +826,7 @@ "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.", "generic_something_went_wrong": "Sorry, something went wrong", + "get_ai_assist": "Get AI Assist", "get_collaborative_benefits": "Get the collaborative benefits from __appName__, even if you prefer to work offline", "get_discounted_plan": "Get discounted plan", "get_error_assist": "Get Error Assist", From c23e84eb372f55513d04baddd5e6d9c848a19a4e Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:06:21 +0100 Subject: [PATCH 089/209] Merge pull request #26273 from overleaf/bg-history-redis-add-persist-worker-to-cron modify existing run-chunk-lifecycle cron job to persist and expire redis queues GitOrigin-RevId: afb94b3e2fba7368cfec11997dfd5b2bbd6321a9 --- .../history-v1/storage/scripts/persist_and_expire_queues.sh | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 services/history-v1/storage/scripts/persist_and_expire_queues.sh diff --git a/services/history-v1/storage/scripts/persist_and_expire_queues.sh b/services/history-v1/storage/scripts/persist_and_expire_queues.sh new file mode 100644 index 0000000000..d9ff60ea31 --- /dev/null +++ b/services/history-v1/storage/scripts/persist_and_expire_queues.sh @@ -0,0 +1,3 @@ +#!/bin/sh +node storage/scripts/persist_redis_chunks.js +node storage/scripts/expire_redis_chunks.js From c227c1e2d9441eea611a0d3bda47367236ba826d Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 10:10:55 +0100 Subject: [PATCH 090/209] Remove some unused variables These miseed the lint rule as they were merged between the last rebase and deploy. GitOrigin-RevId: 16b1117d56f2fc824509b9a0f340dba2ede9902f --- .../js/features/source-editor/extensions/history-ot.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index b10a629189..5f6c8796f0 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -41,7 +41,7 @@ export const shareDocState = StateField.define({ return null }, - update(value, transaction) { + update(value) { // this state is constant return value }, @@ -134,7 +134,7 @@ class ChangeDeletedWidget extends WidgetType { return widget } - eq(old: ChangeDeletedWidget) { + eq() { return true } } @@ -407,7 +407,7 @@ class PositionMapper { }) this.offsets.toCM6.push({ pos: change.range.pos, - map: pos => deletePos - oldOffset, + map: () => deletePos - oldOffset, }) this.offsets.toCM6.push({ pos: change.range.pos + deleteLength, From f904933d6855c614b0ac98325ba57d7e27f93ff2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:41:44 +0100 Subject: [PATCH 091/209] Merge pull request #26180 from overleaf/bg-history-redis-add-queueChanges add queueChanges method to history-v1 GitOrigin-RevId: fb6da79bd5ca40e7cbdcb077ad3a036cc5509ced --- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/queue_changes.js | 75 ++++ .../js/storage/queue_changes.test.js | 416 ++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 services/history-v1/storage/lib/queue_changes.js create mode 100644 services/history-v1/test/acceptance/js/storage/queue_changes.test.js diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 46fa63b689..a07c98c026 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -9,6 +9,7 @@ exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') exports.persistBuffer = require('./lib/persist_buffer') +exports.queueChanges = require('./lib/queue_changes') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/queue_changes.js b/services/history-v1/storage/lib/queue_changes.js new file mode 100644 index 0000000000..6b8d4b22b4 --- /dev/null +++ b/services/history-v1/storage/lib/queue_changes.js @@ -0,0 +1,75 @@ +// @ts-check + +'use strict' + +const redisBackend = require('./chunk_store/redis') +const { BlobStore } = require('./blob_store') +const chunkStore = require('./chunk_store') +const core = require('overleaf-editor-core') +const Chunk = core.Chunk + +/** + * Queues an incoming set of changes after validating them against the current snapshot. + * + * @async + * @function queueChanges + * @param {string} projectId - The project to queue changes for. + * @param {Array} changesToQueue - An array of change objects to be applied and queued. + * @param {number} endVersion - The expected version of the project before these changes are applied. + * This is used for optimistic concurrency control. + * @param {Object} [opts] - Additional options for queuing changes. + * @throws {Chunk.ConflictingEndVersion} If the provided `endVersion` does not match the + * current version of the project. + * @returns {Promise} A promise that resolves with the status returned by the + * `redisBackend.queueChanges` operation. + */ +async function queueChanges(projectId, changesToQueue, endVersion, opts) { + const result = await redisBackend.getHeadSnapshot(projectId) + let currentSnapshot = null + let currentVersion = null + if (result) { + // If we have a snapshot in redis, we can use it to check the current state + // of the project and apply changes to it. + currentSnapshot = result.snapshot + currentVersion = result.version + } else { + // Otherwise, load the latest chunk from the chunk store. + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + // Throw an error if no latest chunk is found, indicating the project has not been initialised. + if (!latestChunk) { + throw new Chunk.NotFoundError(projectId) + } + currentSnapshot = latestChunk.getSnapshot() + currentSnapshot.applyAll(latestChunk.getChanges()) + currentVersion = latestChunk.getEndVersion() + } + + // Ensure the endVersion matches the current version of the project. + if (endVersion !== currentVersion) { + throw new Chunk.ConflictingEndVersion(endVersion, currentVersion) + } + + // Compute the new hollow snapshot to be saved to redis. + const hollowSnapshot = currentSnapshot + const blobStore = new BlobStore(projectId) + await hollowSnapshot.loadFiles('hollow', blobStore) + // Clone the changes to avoid modifying the original ones when computing the hollow snapshot. + const hollowChanges = changesToQueue.map(change => change.clone()) + for (const change of hollowChanges) { + await change.loadFiles('hollow', blobStore) + } + hollowSnapshot.applyAll(hollowChanges, { strict: true }) + const baseVersion = currentVersion + const status = await redisBackend.queueChanges( + projectId, + hollowSnapshot, + baseVersion, + changesToQueue, + opts + ) + return status +} + +module.exports = queueChanges diff --git a/services/history-v1/test/acceptance/js/storage/queue_changes.test.js b/services/history-v1/test/acceptance/js/storage/queue_changes.test.js new file mode 100644 index 0000000000..dbfe8c7e56 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/queue_changes.test.js @@ -0,0 +1,416 @@ +'use strict' + +const { expect } = require('chai') +const sinon = require('sinon') + +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const testFiles = require('./support/test_files.js') +const storage = require('../../../../storage') +const chunkStore = storage.chunkStore +const queueChanges = storage.queueChanges +const redisBackend = require('../../../../storage/lib/chunk_store/redis') + +const core = require('overleaf-editor-core') +const AddFileOperation = core.AddFileOperation +const EditFileOperation = core.EditFileOperation +const TextOperation = core.TextOperation +const Change = core.Change +const Chunk = core.Chunk +const File = core.File +const Snapshot = core.Snapshot +const BlobStore = storage.BlobStore +const persistChanges = storage.persistChanges + +describe('queueChanges', function () { + let limitsToPersistImmediately + before(function () { + // Used to provide a limit which forces us to persist all of the changes + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChanges: 10, + maxChunkChanges: 10, + } + }) + + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + afterEach(function () { + sinon.restore() + }) + + it('queues changes when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Add a test file to the blob store + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + + // Compute the expected snapshot after applying the changes + const expectedSnapshot = new Snapshot() + await expectedSnapshot.loadFiles('hollow', blobStore) + for (const change of changesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(expectedSnapshot, { strict: true }) + } + + // Confirm that state in redis matches the expected snapshot and changes queue + const expectedVersionInRedis = endVersion + changesToQueue.length + expect(redisState.headVersion).to.equal(expectedVersionInRedis) + expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw())) + }) + + it('queues changes when redis has no snapshot (falls back to chunkStore with an existing chunk)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in the chunk store using the "Hello World" test file + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + const change = new Change( + [ + new AddFileOperation( + 'hello.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const initialChanges = [change] + const initialVersion = 0 + + const result = await persistChanges( + projectId, + initialChanges, + limitsToPersistImmediately, + initialVersion + ) + // Compute the state after the initial changes are persisted for later comparison + const endVersion = initialVersion + initialChanges.length + const { currentChunk } = result + const originalSnapshot = result.currentChunk.getSnapshot() + await originalSnapshot.loadFiles('hollow', blobStore) + originalSnapshot.applyAll(currentChunk.getChanges()) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Prepare a change to edit the existing file + const editFileOp = new EditFileOperation( + 'hello.tex', + new TextOperation() + .insert('Hello') + .retain(testFiles.HELLO_TXT_UTF8_LENGTH) + ) + const editFileChange = new Change([editFileOp], new Date(), []) + const changesToQueue = [editFileChange] + + // Queue the changes to edit the existing file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + + // Compute the expected snapshot after applying the changes + const expectedSnapshot = originalSnapshot.clone() + await expectedSnapshot.loadFiles('hollow', blobStore) + expectedSnapshot.applyAll(changesToQueue) + + // Confirm that state in redis matches the expected snapshot and changes queue + const expectedVersionInRedis = endVersion + changesToQueue.length + expect(redisState.headVersion).to.equal(expectedVersionInRedis) + expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw())) + }) + + it('queues changes when redis has a snapshot with existing changes', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in redis using the "Hello World" test file + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + const initialChangeOp = new AddFileOperation( + 'existing.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ) + const initialChange = new Change([initialChangeOp], new Date(), []) + const initialChangesToQueue = [initialChange] + const versionBeforeInitialQueue = 0 + + // Queue the initial changes + const status = await queueChanges( + projectId, + initialChangesToQueue, + versionBeforeInitialQueue + ) + // Confirm that the initial changes were queued successfully + expect(status).to.equal('ok') + const versionAfterInitialQueue = + versionBeforeInitialQueue + initialChangesToQueue.length + + // Compute the snapshot after the initial changes for later use + const initialSnapshot = new Snapshot() + await initialSnapshot.loadFiles('hollow', blobStore) + for (const change of initialChangesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(initialSnapshot, { strict: true }) + } + + // Now prepare some subsequent changes for the queue + await blobStore.putFile(testFiles.path('graph.png')) + const addFileOp = new AddFileOperation( + 'graph.png', + File.fromHash(testFiles.GRAPH_PNG_HASH) + ) + const addFileChange = new Change([addFileOp], new Date(), []) + const editFileOp = new EditFileOperation( + 'existing.tex', + new TextOperation() + .insert('Hello') + .retain(testFiles.HELLO_TXT_UTF8_LENGTH) + ) + const editFileChange = new Change([editFileOp], new Date(), []) + + const subsequentChangesToQueue = [addFileChange, editFileChange] + const versionBeforeSubsequentQueue = versionAfterInitialQueue + + // Queue the subsequent changes + const subsequentStatus = await queueChanges( + projectId, + subsequentChangesToQueue, + versionBeforeSubsequentQueue + ) + expect(subsequentStatus).to.equal('ok') + + // Compute the expected snapshot after applying all changes + const expectedSnapshot = initialSnapshot.clone() + await expectedSnapshot.loadFiles('hollow', blobStore) + for (const change of subsequentChangesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(expectedSnapshot, { strict: true }) + } + + // Confirm that state in redis matches the expected snapshot and changes queue + const finalRedisState = await redisBackend.getState(projectId) + expect(finalRedisState).to.not.be.null + const expectedFinalVersion = + versionBeforeSubsequentQueue + subsequentChangesToQueue.length + expect(finalRedisState.headVersion).to.equal(expectedFinalVersion) + expect(finalRedisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + const allQueuedChangesRaw = initialChangesToQueue + .concat(subsequentChangesToQueue) + .map(c => c.toRaw()) + expect(finalRedisState.changes).to.deep.equal(allQueuedChangesRaw) + }) + + it('skips queuing changes when there is no snapshot and the onlyIfExists flag is set', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Add a test file to the blob store + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion, { + onlyIfExists: true, + }) + expect(status).to.equal('ignore') + + // Verify that the state in redis has not changed + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.deep.equal(initialRedisState) + }) + + it('creates an initial hollow snapshot when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + expect(redisState.headSnapshot.files['test.tex']).to.deep.equal({ + stringLength: testFiles.HELLO_TXT_UTF8_LENGTH, + }) + }) + + it('throws ConflictingEndVersion if endVersion does not match current version (from chunkStore)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + // Initialise an empty project in the chunk store + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + + // Prepare a change to add a file + const change = new Change( + [new AddFileOperation('test.tex', File.fromString(''))], + new Date(), + [] + ) + const changesToQueue = [change] + const incorrectEndVersion = 1 + + // Attempt to queue the changes with an incorrect endVersion (1 instead of 0) + await expect(queueChanges(projectId, changesToQueue, incorrectEndVersion)) + .to.be.rejectedWith(Chunk.ConflictingEndVersion) + .and.eventually.satisfies(err => { + expect(err.info).to.have.property( + 'clientEndVersion', + incorrectEndVersion + ) + expect(err.info).to.have.property('latestEndVersion', 0) + return true + }) + + // Verify that the state in redis has not changed + const redisStateAfterError = await redisBackend.getState(projectId) + expect(redisStateAfterError).to.deep.equal(initialRedisState) + }) + + it('throws ConflictingEndVersion if endVersion does not match current version (from redis snapshot)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in the redis with a test file + await chunkStore.initializeProject(projectId) + const initialChange = new Change( + [new AddFileOperation('initial.tex', File.fromString('content'))], + new Date(), + [] + ) + const initialChangesToQueue = [initialChange] + const versionBeforeInitialQueue = 0 + + // Queue the initial changes + await queueChanges( + projectId, + initialChangesToQueue, + versionBeforeInitialQueue + ) + const versionInRedisAfterSetup = + versionBeforeInitialQueue + initialChangesToQueue.length + + // Confirm that the initial changes were queued successfully + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState).to.not.be.null + expect(initialRedisState.headVersion).to.equal(versionInRedisAfterSetup) + + // Now prepare a subsequent change for the queue + const subsequentChange = new Change( + [new AddFileOperation('another.tex', File.fromString(''))], + new Date(), + [] + ) + const subsequentChangesToQueue = [subsequentChange] + const incorrectEndVersion = 0 + + // Attempt to queue the changes with an incorrect endVersion (0 instead of 1) + await expect( + queueChanges(projectId, subsequentChangesToQueue, incorrectEndVersion) + ) + .to.be.rejectedWith(Chunk.ConflictingEndVersion) + .and.eventually.satisfies(err => { + expect(err.info).to.have.property( + 'clientEndVersion', + incorrectEndVersion + ) + expect(err.info).to.have.property( + 'latestEndVersion', + versionInRedisAfterSetup + ) + return true + }) + + // Verify that the state in redis has not changed + const redisStateAfterError = await redisBackend.getState(projectId) + expect(redisStateAfterError).to.not.be.null + expect(redisStateAfterError).to.deep.equal(initialRedisState) + }) +}) From 2d0706591bddce9539ac990006121da680834d69 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:41:55 +0100 Subject: [PATCH 092/209] Merge pull request #26219 from overleaf/bg-history-redis-fix-loadAtTimestamp correct startVersion calculation in loadAtTimestamp GitOrigin-RevId: ad46aae47c0769943e787199d68e895cf139bb56 --- services/history-v1/storage/lib/chunk_store/index.js | 3 ++- .../test/acceptance/js/storage/chunk_store.test.js | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index 6dab84f929..53b8fb8245 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -190,6 +190,7 @@ async function loadAtTimestamp(projectId, timestamp, opts = {}) { const chunkRecord = await backend.getChunkForTimestamp(projectId, timestamp) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { const nonPersistedChanges = await getChunkExtension( @@ -200,7 +201,7 @@ async function loadAtTimestamp(projectId, timestamp, opts = {}) { } await lazyLoadHistoryFiles(history, batchBlobStore) - return new Chunk(history, chunkRecord.endVersion - history.countChanges()) + return new Chunk(history, startVersion) } /** diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index da70467934..bc2bae4660 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -509,6 +509,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it("doesn't include the queued changes when getting another chunk by timestamp", async function () { From c81cc4055e3bf325a2704f9dccfccd9971e66aa4 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:08 +0100 Subject: [PATCH 093/209] Merge pull request #26220 from overleaf/bg-history-redis-fix-loadAtVersion-startVersion correct startVersion calculation in loadAtVersion GitOrigin-RevId: b81c30dcab90b137169a4bddef3c22f44a957f68 --- .../storage/lib/chunk_store/index.js | 3 ++- .../acceptance/js/storage/chunk_store.test.js | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index 53b8fb8245..cbdf3d2dcf 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -157,6 +157,7 @@ async function loadAtVersion(projectId, version, opts = {}) { }) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { const nonPersistedChanges = await getChunkExtension( @@ -167,7 +168,7 @@ async function loadAtVersion(projectId, version, opts = {}) { } await lazyLoadHistoryFiles(history, batchBlobStore) - return new Chunk(history, chunkRecord.endVersion - history.countChanges()) + return new Chunk(history, startVersion) } /** diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index bc2bae4660..c6c33404d6 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -498,6 +498,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it('includes the queued changes when getting the latest chunk by timestamp', async function () { @@ -524,6 +530,10 @@ describe('chunkStore', function () { ) const expectedChanges = secondChunk.getChanges() expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + secondChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) }) it('includes the queued changes when getting the latest chunk by version', async function () { @@ -535,6 +545,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it("doesn't include the queued changes when getting another chunk by version", async function () { @@ -544,6 +560,10 @@ describe('chunkStore', function () { ) const expectedChanges = secondChunk.getChanges() expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + secondChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) }) }) From fec6dde00fac4f75096c3d4d0b73b5baf681b40f Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:18 +0100 Subject: [PATCH 094/209] Merge pull request #26203 from overleaf/bg-history-redis-fix-loadAtVersion Extend loadAtVersion to handle nonpersisted versions GitOrigin-RevId: 22060605ea7bb89a8d4d61bafab8f63b94d59067 --- .../storage/lib/chunk_store/index.js | 30 +++++++++- .../acceptance/js/storage/chunk_store.test.js | 56 ++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index cbdf3d2dcf..286a8d8764 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -151,20 +151,44 @@ async function loadAtVersion(projectId, version, opts = {}) { const backend = getBackend(projectId) const blobStore = new BlobStore(projectId) const batchBlobStore = new BatchBlobStore(blobStore) + const latestChunkMetadata = await getLatestChunkMetadata(projectId) - const chunkRecord = await backend.getChunkForVersion(projectId, version, { - preferNewer: opts.preferNewer, - }) + // When loading a chunk for a version there are three cases to consider: + // 1. If `persistedOnly` is true, we always use the requested version + // to fetch the chunk. + // 2. If `persistedOnly` is false and the requested version is in the + // persisted chunk version range, we use the requested version. + // 3. If `persistedOnly` is false and the requested version is ahead of + // the persisted chunk versions, we fetch the latest chunk and see if + // the non-persisted changes include the requested version. + const targetChunkVersion = opts.persistedOnly + ? version + : Math.min(latestChunkMetadata.endVersion, version) + + const chunkRecord = await backend.getChunkForVersion( + projectId, + targetChunkVersion, + { + preferNewer: opts.preferNewer, + } + ) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { + // Try to extend the chunk with any non-persisted changes that + // follow the chunk's end version. const nonPersistedChanges = await getChunkExtension( projectId, chunkRecord.endVersion ) history.pushChanges(nonPersistedChanges) + + // Check that the changes do actually contain the requested version + if (version > chunkRecord.endVersion + nonPersistedChanges.length) { + throw new Chunk.VersionNotFoundError(projectId, version) + } } await lazyLoadHistoryFiles(history, batchBlobStore) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index c6c33404d6..8b06b8e412 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -470,6 +470,8 @@ describe('chunkStore', function () { describe('with changes queued in the Redis buffer', function () { let queuedChanges + const firstQueuedChangeTimestamp = new Date('2017-01-01T00:01:00') + const lastQueuedChangeTimestamp = new Date('2017-01-01T00:02:00') beforeEach(async function () { const snapshot = thirdChunk.getSnapshot() @@ -481,7 +483,15 @@ describe('chunkStore', function () { 'in-redis.tex', File.createLazyFromBlobs(blob) ), - new Date() + firstQueuedChangeTimestamp + ), + makeChange( + // Add a second change to make the buffer more interesting + Operation.editFile( + 'in-redis.tex', + TextOperation.fromJSON({ textOperation: ['hello'] }) + ), + lastQueuedChangeTimestamp ), ] await redisBackend.queueChanges( @@ -504,6 +514,9 @@ describe('chunkStore', function () { expect(chunk.getEndVersion()).to.equal( thirdChunk.getEndVersion() + queuedChanges.length ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) }) it('includes the queued changes when getting the latest chunk by timestamp', async function () { @@ -534,6 +547,7 @@ describe('chunkStore', function () { secondChunk.getStartVersion() ) expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) + expect(chunk.getEndTimestamp()).to.deep.equal(secondChunkTimestamp) }) it('includes the queued changes when getting the latest chunk by version', async function () { @@ -551,6 +565,9 @@ describe('chunkStore', function () { expect(chunk.getEndVersion()).to.equal( thirdChunk.getEndVersion() + queuedChanges.length ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) }) it("doesn't include the queued changes when getting another chunk by version", async function () { @@ -564,6 +581,43 @@ describe('chunkStore', function () { secondChunk.getStartVersion() ) expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) + expect(chunk.getEndTimestamp()).to.deep.equal(secondChunkTimestamp) + }) + + it('loads a version that is only in the Redis buffer', async function () { + const versionInRedis = thirdChunk.getEndVersion() + 1 // the first change in Redis + const chunk = await chunkStore.loadAtVersion( + projectId, + versionInRedis + ) + // The chunk should contain changes from the thirdChunk and the queuedChanges + const expectedChanges = thirdChunk + .getChanges() + .concat(queuedChanges) + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) + }) + + it('throws an error when loading a version beyond the Redis buffer', async function () { + const versionBeyondRedis = + thirdChunk.getEndVersion() + queuedChanges.length + 1 + await expect( + chunkStore.loadAtVersion(projectId, versionBeyondRedis) + ) + .to.be.rejectedWith(chunkStore.VersionOutOfBoundsError) + .and.eventually.satisfy(err => { + expect(err.info).to.have.property('projectId', projectId) + expect(err.info).to.have.property('version', versionBeyondRedis) + return true + }) }) }) From 2a833aa23a007eaa9dbb3ab2d667a11999f5f800 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:28 +0100 Subject: [PATCH 095/209] Merge pull request #26250 from overleaf/bg-history-redis-add-return-value-to-persistBuffer provide return value from persistBuffer GitOrigin-RevId: ba52ff42b91ffe9adc23ab0461fa836540735563 --- .../history-v1/storage/lib/persist_buffer.js | 14 ++- .../js/storage/persist_buffer.test.mjs | 96 +++++++++++++++++-- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 1f508c43f3..9534e5834a 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -58,7 +58,17 @@ async function persistBuffer(projectId, limits) { // to match the current endVersion. This shouldn't be needed // unless a worker failed to update the persisted version. await redisBackend.setPersistedVersion(projectId, endVersion) - return + const { chunk } = await chunkStore.loadByChunkRecord( + projectId, + latestChunkMetadata + ) + // Return the result in the same format as persistChanges + // so that the caller can handle it uniformly. + return { + numberOfChangesPersisted: changesToPersist.length, + originalEndVersion: endVersion, + currentChunk: chunk, + } } logger.debug( @@ -160,6 +170,8 @@ async function persistBuffer(projectId, limits) { { projectId, finalPersistedVersion: newEndVersion }, 'persistBuffer operation completed successfully' ) + + return persistResult } module.exports = persistBuffer diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 216399f676..138a70e626 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -92,15 +92,34 @@ describe('persistBuffer', function () { await redisBackend.setPersistedVersion(projectId, initialVersion) // Persist the changes from Redis to the chunk store - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) - const latestChunk = await chunkStore.loadLatest(projectId) + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal( + changesToQueue.length + ) + expect(persistResult.originalEndVersion).to.equal(initialVersion + 1) + expect(persistResult.resyncNeeded).to.be.false + + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) expect(latestChunk).to.exist expect(latestChunk.getStartVersion()).to.equal(initialVersion) expect(latestChunk.getEndVersion()).to.equal(finalHeadVersion) expect(latestChunk.getChanges().length).to.equal( changesToQueue.length + 1 ) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(latestChunk).to.deep.equal(persistResult.currentChunk) const chunkSnapshot = latestChunk.getSnapshot() expect(Object.keys(chunkSnapshot.getFileMap()).length).to.equal(1) @@ -196,9 +215,28 @@ describe('persistBuffer', function () { persistedChunkEndVersion ) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) - const latestChunk = await chunkStore.loadLatest(projectId) + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal( + redisChangesToPush.length + ) + expect(persistResult.originalEndVersion).to.equal( + persistedChunkEndVersion + ) + expect(persistResult.resyncNeeded).to.be.false + + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) expect(latestChunk).to.exist expect(latestChunk.getStartVersion()).to.equal(0) expect(latestChunk.getEndVersion()).to.equal( @@ -215,6 +253,9 @@ describe('persistBuffer', function () { finalHeadVersionAfterRedisPush ) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(persistResult.currentChunk).to.deep.equal(latestChunk) + const nonPersisted = await redisBackend.getNonPersistedChanges( projectId, finalHeadVersionAfterRedisPush @@ -287,8 +328,19 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) - // Persist buffer (which should do nothing as there are no new changes) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) + + const currentChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(persistResult).to.deep.equal({ + numberOfChangesPersisted: 0, + originalEndVersion: persistedChunkEndVersion, + currentChunk, + }) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -324,7 +376,20 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) // Persist buffer (which should do nothing as there are no new changes) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) + + // Check the return value + const currentChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(persistResult).to.deep.equal({ + numberOfChangesPersisted: 0, + originalEndVersion: persistedChunkEndVersion, + currentChunk, + }) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -411,7 +476,19 @@ describe('persistBuffer', function () { maxChangeTimestamp: new Date(twoHoursAgo), // they will be persisted if any change is older than 2 hours } - await persistBuffer(projectId, restrictiveLimits) + const persistResult = await persistBuffer(projectId, restrictiveLimits) + + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal(2) // change1 + change2 + expect(persistResult.originalEndVersion).to.equal( + versionAfterInitialSetup + ) + expect(persistResult.resyncNeeded).to.be.false // Check the latest persisted chunk, it should only have the initial file and the first two changes const latestChunk = await chunkStore.loadLatest(projectId, { @@ -423,6 +500,9 @@ describe('persistBuffer', function () { const expectedEndVersion = versionAfterInitialSetup + 2 // Persisted two changes from the queue expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(persistResult.currentChunk).to.deep.equal(latestChunk) + // Check persisted version in Redis const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(expectedEndVersion) From fdd0d955547c21beab963671018855cb39af239d Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 11:46:18 +0100 Subject: [PATCH 096/209] Merge pull request #26293 from overleaf/bg-history-redis-fix-persist-worker add missing load global blobs from persist worker GitOrigin-RevId: ae9393f2353fb4d5afe349aa7d0a26bab80c7f53 --- services/history-v1/storage/scripts/persist_redis_chunks.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 414fbf3458..20963ba90f 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -7,6 +7,11 @@ const { client } = require('../lib/mongodb.js') const { scanAndProcessDueItems } = require('../lib/scan') const persistBuffer = require('../lib/persist_buffer') const { claimPersistJob } = require('../lib/chunk_store/redis') +const { loadGlobalBlobs } = require('../lib/blob_store/index.js') + +// Something is registering 11 listeners, over the limit of 10, which generates +// a lot of warning noise. +require('node:events').EventEmitter.defaultMaxListeners = 11 const rclient = redis.rclientHistory @@ -33,6 +38,7 @@ async function persistProjectAction(projectId) { } async function runPersistChunks() { + await loadGlobalBlobs() await scanAndProcessDueItems( rclient, 'persistChunks', From 7c23655c7927f5dc8e8022a185a0be3da87d705d Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 10 Jun 2025 13:26:28 +0100 Subject: [PATCH 097/209] Merge pull request #26177 from overleaf/mj-ide-history-file-tree [web] Editor redesign: Update history view file tree GitOrigin-RevId: bb0fe871837ffac6e1af6c18c7c1ae651dee7f81 --- .../file-tree/history-file-tree-doc.tsx | 22 ++++--- .../file-tree/history-file-tree-folder.tsx | 18 ++++-- .../bootstrap-5/pages/editor/history.scss | 58 ++++++++++++++++++- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx index 3b788eb046..e3543ef527 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx @@ -1,9 +1,12 @@ import { memo } from 'react' import classNames from 'classnames' import HistoryFileTreeItem from './history-file-tree-item' -import iconTypeFromName from '../../../file-tree/util/icon-type-from-name' +import iconTypeFromName, { + newEditorIconTypeFromName, +} from '../../../file-tree/util/icon-type-from-name' import type { FileDiff } from '../../services/types/file' import MaterialIcon from '@/shared/components/material-icon' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' type HistoryFileTreeDocProps = { file: FileDiff @@ -20,6 +23,16 @@ function HistoryFileTreeDoc({ onClick, onKeyDown, }: HistoryFileTreeDocProps) { + const newEditor = useIsNewEditorEnabled() + const icon = newEditor ? ( + + ) : ( + + ) return (
  • - } + icons={icon} />
  • ) diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx index 6c2c912f8c..44cb7f2921 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx @@ -6,6 +6,7 @@ import HistoryFileTreeFolderList from './history-file-tree-folder-list' import type { HistoryDoc, HistoryFileTree } from '../../utils/file-tree' import MaterialIcon from '@/shared/components/material-icon' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' type HistoryFileTreeFolderProps = { name: string @@ -35,6 +36,7 @@ function HistoryFileTreeFolder({ docs, }: HistoryFileTreeFolderProps) { const { t } = useTranslation() + const newEditor = useIsNewEditorEnabled() const [expanded, setExpanded] = useState(() => { return hasChanges({ name, folders, docs }) @@ -52,10 +54,12 @@ function HistoryFileTreeFolder({ className="file-tree-expand-icon" /> - + {!newEditor && ( + + )} ) @@ -79,7 +83,11 @@ function HistoryFileTreeFolder({ {expanded ? ( - + ) : null} ) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss index 1caeb22c1d..1a73840fb4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss @@ -1,4 +1,5 @@ :root { + --history-react-icon-color: var(--content-disabled); --history-react-header-bg: var(--bg-dark-secondary); --history-react-header-color: var(--content-primary-dark); --history-react-separator-color: var(--border-divider-dark); @@ -10,6 +11,61 @@ --history-react-separator-color: var(--border-divider); } +.ide-redesign-main { + --history-react-header-bg: var(--bg-primary-themed); + --history-react-header-color: var(--content-primary-themed); + --history-react-icon-color: var(--file-tree-item-color); + + .history-file-tree { + ul.history-file-tree-list { + padding: var(--spacing-02); + + .history-file-tree-item > ul, + ul[role='tree'] { + border-left: 1px solid + color-mix(in srgb, var(--border-primary-themed) 24%, transparent); + margin-left: 14px !important; + margin-top: 0; + } + + li { + padding: var(--spacing-02); + padding-right: 0; + margin-left: 0; + } + + .history-file-tree-item { + border-radius: var(--border-radius-base); + + .history-file-tree-item-name-wrapper { + .history-file-tree-item-badge { + margin-right: var(--spacing-02); + } + } + + &::before { + display: none; + } + + .material-symbols { + &.file-tree-expand-icon { + margin-left: 0; + vertical-align: middle; + } + + &.file-tree-icon { + margin-left: 0; + } + } + } + } + } + + ul[role='tree'].history-file-tree-list-inner { + padding-left: 10px; + } +} + history-root { height: 100%; display: block; @@ -510,7 +566,7 @@ history-root { } .material-symbols { - color: var(--content-disabled); + color: var(--history-react-icon-color); &.file-tree-icon { margin-right: var(--spacing-02); From 0397b022145a43a933133c7f289694f10a9b3031 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 10 Jun 2025 13:26:38 +0100 Subject: [PATCH 098/209] Merge pull request #26221 from overleaf/mj-history-dark-mode-entries [web] Editor redesign: Add dark mode to history entries GitOrigin-RevId: 16c9743bdee85dc3825ce6e9901a0107956205ca --- .../bootstrap-5/pages/editor/history.scss | 91 ++++++++++++++----- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss index 1a73840fb4..db59639cd1 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss @@ -3,18 +3,53 @@ --history-react-header-bg: var(--bg-dark-secondary); --history-react-header-color: var(--content-primary-dark); --history-react-separator-color: var(--border-divider-dark); + --history-change-list-bg: var(--bg-light-primary); + --history-change-entry-color: var(--content-primary); + --history-change-entry-metadata-color: var(--content-secondary); + --history-change-list-divider: var(--border-divider); + --history-change-entry-hover-bg: var(--bg-light-secondary); + --history-loading-bg: var(--bg-light-secondary); + --history-change-entry-dropdown-button-bg: rgb(var(--bg-dark-primary) 0.08); + --history-change-entry-border-color: var(--green-50); + --history-change-entry-within-selected-bg: var(--bg-light-secondary); + --history-change-entry-within-selected-hover-bg: rgb($neutral-90, 8%); + --history-change-list-gradient: linear-gradient(black 35%, transparent); + --history-change-entry-selected-bg: var(--bg-accent-03); + --history-change-entry-selected-hover-bg: rgb($green-70, 16%); } @include theme('light') { --history-react-header-bg: var(--bg-light-primary); --history-react-header-color: var(--content-primary); --history-react-separator-color: var(--border-divider); + + .ide-redesign-main { + --history-change-entry-within-selected-bg: var(--bg-light-secondary); + --history-change-entry-within-selected-hover-bg: rgb(var(--neutral-90) 8%); + --history-change-entry-selected-bg: var(--bg-accent-03); + --history-change-entry-selected-hover-bg: rgb(var(--green-70 0.16)); + } } .ide-redesign-main { --history-react-header-bg: var(--bg-primary-themed); --history-react-header-color: var(--content-primary-themed); --history-react-icon-color: var(--file-tree-item-color); + --history-loading-bg: var(--bg-secondary-themed); + --history-change-list-gradient: linear-gradient(black 35%, transparent); + --history-change-list-bg: var(--bg-primary-themed); + --history-change-list-divider: var(--border-divider-themed); + --history-change-entry-metadata-color: var(--content-secondary-themed); + --history-change-entry-color: var(--content-primary-themed); + --history-change-entry-hover-bg: var(--bg-secondary-themed); + --history-change-entry-border-color: var(--green-50); + --history-change-entry-dropdown-button-bg: rgb(var(--bg-dark-primary) 0.08); + + // Dark mode specific variables + --history-change-entry-within-selected-bg: var(--neutral-80); + --history-change-entry-within-selected-hover-bg: rgb(var(--white) 0.08); + --history-change-entry-selected-bg: var(--green-70); + --history-change-entry-selected-hover-bg: rgb(var(--green-60) 0.08); .history-file-tree { ul.history-file-tree-list { @@ -83,7 +118,7 @@ history-root { display: flex; justify-content: center; height: 100%; - background-color: var(--bg-light-primary); + background-color: var(--history-change-list-bg); .history-header { @include body-sm; @@ -163,7 +198,7 @@ history-root { } .history-version-day { - background-color: white; + background-color: var(--history-change-list-bg); position: sticky; z-index: 1; top: 0; @@ -183,51 +218,56 @@ history-root { cursor: pointer; &:hover { - background-color: var(--bg-light-secondary); + background-color: var(--history-change-entry-hover-bg); } } &.history-version-selected { - background-color: var(--bg-accent-03); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); padding-left: calc( var(--history-change-list-padding) - var(--spacing-02) ); } &.history-version-selected.history-version-selectable:hover { - background-color: rgb($green-70, 16%); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-hover-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } &.history-version-within-selected { - background-color: var(--bg-light-secondary); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-within-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } &.history-version-within-selected:hover { - background-color: rgb($neutral-90, 8%); + background-color: var(--history-change-entry-within-selected-hover-bg); } } .history-version-main-details { - color: var(--content-primary); + color: var(--history-change-entry-color); } .version-element-within-selected { - background-color: var(--bg-light-secondary); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-within-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } .version-element-selected { - background-color: var(--bg-accent-03); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } .history-version-metadata-time { display: block; margin-bottom: var(--spacing-02); - color: var(--content-primary); + color: var(--history-change-entry-color); &:last-child { margin-bottom: initial; @@ -282,7 +322,7 @@ history-root { .history-version-metadata-users, .history-version-origin, .history-version-saved-by { - color: var(--content-secondary); + color: var(--history-change-entry-metadata-color); } .history-version-change-action { @@ -290,7 +330,7 @@ history-root { } .history-version-change-doc { - color: var(--content-primary); + color: var(--history-change-entry-color); overflow-wrap: anywhere; white-space: pre-wrap; } @@ -301,7 +341,7 @@ history-root { .history-version-divider { margin: 0; - border-color: var(--border-divider); + border-color: var(--history-change-list-divider); } .history-version-badge { @@ -332,7 +372,7 @@ history-root { position: sticky; bottom: 0; padding: var(--spacing-05) 0; - background-color: var(--bg-light-secondary); + background-color: var(--history-loading-bg); text-align: center; } @@ -344,7 +384,7 @@ history-root { .dropdown.open { .history-version-dropdown-menu-btn { - background-color: rgb(var(--bg-dark-primary) 0.08); + background-color: var(--history-change-entry-dropdown-button-bg); box-shadow: initial; } } @@ -354,6 +394,7 @@ history-root { @include reset-button; @include action-button; + color: var(--history-change-entry-color); padding: 0; width: 30px; height: 30px; @@ -400,7 +441,7 @@ history-root { .history-version-faded .history-version-details { max-height: 6em; - @include mask-image(linear-gradient(black 35%, transparent)); + @include mask-image(var(--history-change-list-gradient)); overflow: hidden; } @@ -470,7 +511,7 @@ history-root { } .history-dropdown-icon { - color: var(--content-primary); + color: var(--history-change-entry-color); } .history-dropdown-icon-inverted { @@ -605,3 +646,7 @@ history-root { .history-error { padding: var(--spacing-06); } + +.doc-container { + background: var(--bg-light-primary); +} From 25c3699862ede6ca975ae90f9b747ad25e2df46e Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 10 Jun 2025 15:21:00 +0200 Subject: [PATCH 099/209] [docstore] finish async/await migration (#26295) * [docstore] DocManager.getDocLines returns flat content * [docstore] peekDoc throws NotFoundError, skip check in HttpController * [docstore] getFullDoc throws NotFoundError, skip check in HttpController * [docstore] migrate HealthChecker to async/await * [docstore] migrate HttpController to async/await * [docstore] remove .promises/callbackify wrapper from all the modules GitOrigin-RevId: a9938b03cdd2b5e80c2c999039e8f63b20d59dc5 --- package-lock.json | 1 + services/docstore/app/js/DocArchiveManager.js | 31 +- services/docstore/app/js/DocManager.js | 50 ++- services/docstore/app/js/Errors.js | 3 + services/docstore/app/js/HealthChecker.js | 84 ++--- services/docstore/app/js/HttpController.js | 288 +++++++----------- services/docstore/app/js/MongoManager.js | 44 +-- services/docstore/app/js/StreamToBuffer.js | 6 +- services/docstore/package.json | 1 + .../test/acceptance/js/HealthCheckerTest.js | 28 ++ .../acceptance/js/helpers/DocstoreClient.js | 7 + .../test/unit/js/DocArchiveManagerTests.js | 212 ++++++------- .../docstore/test/unit/js/DocManagerTests.js | 224 +++++++------- .../test/unit/js/HttpControllerTests.js | 159 +++++----- .../test/unit/js/MongoManagerTests.js | 40 ++- 15 files changed, 534 insertions(+), 644 deletions(-) create mode 100644 services/docstore/test/acceptance/js/HealthCheckerTest.js diff --git a/package-lock.json b/package-lock.json index c0967e0977..ce75c110c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42871,6 +42871,7 @@ "services/docstore": { "name": "@overleaf/docstore", "dependencies": { + "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/docstore/app/js/DocArchiveManager.js b/services/docstore/app/js/DocArchiveManager.js index 4390afe18f..d332a27651 100644 --- a/services/docstore/app/js/DocArchiveManager.js +++ b/services/docstore/app/js/DocArchiveManager.js @@ -1,5 +1,4 @@ -const { callbackify } = require('node:util') -const MongoManager = require('./MongoManager').promises +const MongoManager = require('./MongoManager') const Errors = require('./Errors') const logger = require('@overleaf/logger') const Settings = require('@overleaf/settings') @@ -8,29 +7,12 @@ const { ReadableString } = require('@overleaf/stream-utils') const RangeManager = require('./RangeManager') const PersistorManager = require('./PersistorManager') const pMap = require('p-map') -const { streamToBuffer } = require('./StreamToBuffer').promises +const { streamToBuffer } = require('./StreamToBuffer') const { BSON } = require('mongodb-legacy') const PARALLEL_JOBS = Settings.parallelArchiveJobs const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize -module.exports = { - archiveAllDocs: callbackify(archiveAllDocs), - archiveDoc: callbackify(archiveDoc), - unArchiveAllDocs: callbackify(unArchiveAllDocs), - unarchiveDoc: callbackify(unarchiveDoc), - destroyProject: callbackify(destroyProject), - getDoc: callbackify(getDoc), - promises: { - archiveAllDocs, - archiveDoc, - unArchiveAllDocs, - unarchiveDoc, - destroyProject, - getDoc, - }, -} - async function archiveAllDocs(projectId) { if (!_isArchivingEnabled()) { return @@ -225,3 +207,12 @@ function _isArchivingEnabled() { return true } + +module.exports = { + archiveAllDocs, + archiveDoc, + unArchiveAllDocs, + unarchiveDoc, + destroyProject, + getDoc, +} diff --git a/services/docstore/app/js/DocManager.js b/services/docstore/app/js/DocManager.js index a9ed99425c..9b80f83eb9 100644 --- a/services/docstore/app/js/DocManager.js +++ b/services/docstore/app/js/DocManager.js @@ -5,7 +5,6 @@ const _ = require('lodash') const DocArchive = require('./DocArchiveManager') const RangeManager = require('./RangeManager') const Settings = require('@overleaf/settings') -const { callbackifyAll } = require('@overleaf/promise-utils') const { setTimeout } = require('node:timers/promises') /** @@ -29,7 +28,7 @@ const DocManager = { throw new Error('must include inS3 when getting doc') } - const doc = await MongoManager.promises.findDoc(projectId, docId, filter) + const doc = await MongoManager.findDoc(projectId, docId, filter) if (doc == null) { throw new Errors.NotFoundError( @@ -38,7 +37,7 @@ const DocManager = { } if (doc.inS3) { - await DocArchive.promises.unarchiveDoc(projectId, docId) + await DocArchive.unarchiveDoc(projectId, docId) return await DocManager._getDoc(projectId, docId, filter) } @@ -46,7 +45,7 @@ const DocManager = { }, async isDocDeleted(projectId, docId) { - const doc = await MongoManager.promises.findDoc(projectId, docId, { + const doc = await MongoManager.findDoc(projectId, docId, { deleted: true, }) @@ -74,7 +73,7 @@ const DocManager = { // returns the doc without any version information async _peekRawDoc(projectId, docId) { - const doc = await MongoManager.promises.findDoc(projectId, docId, { + const doc = await MongoManager.findDoc(projectId, docId, { lines: true, rev: true, deleted: true, @@ -91,7 +90,7 @@ const DocManager = { if (doc.inS3) { // skip the unarchiving to mongo when getting a doc - const archivedDoc = await DocArchive.promises.getDoc(projectId, docId) + const archivedDoc = await DocArchive.getDoc(projectId, docId) Object.assign(doc, archivedDoc) } @@ -102,7 +101,7 @@ const DocManager = { // without unarchiving it (avoids unnecessary writes to mongo) async peekDoc(projectId, docId) { const doc = await DocManager._peekRawDoc(projectId, docId) - await MongoManager.promises.checkRevUnchanged(doc) + await MongoManager.checkRevUnchanged(doc) return doc }, @@ -111,16 +110,18 @@ const DocManager = { lines: true, inS3: true, }) - return doc + if (!doc) throw new Errors.NotFoundError() + if (!Array.isArray(doc.lines)) throw new Errors.DocWithoutLinesError() + return doc.lines.join('\n') }, async getAllDeletedDocs(projectId, filter) { - return await MongoManager.promises.getProjectsDeletedDocs(projectId, filter) + return await MongoManager.getProjectsDeletedDocs(projectId, filter) }, async getAllNonDeletedDocs(projectId, filter) { - await DocArchive.promises.unArchiveAllDocs(projectId) - const docs = await MongoManager.promises.getProjectsDocs( + await DocArchive.unArchiveAllDocs(projectId) + const docs = await MongoManager.getProjectsDocs( projectId, { include_deleted: false }, filter @@ -132,11 +133,7 @@ const DocManager = { }, async projectHasRanges(projectId) { - const docs = await MongoManager.promises.getProjectsDocs( - projectId, - {}, - { _id: 1 } - ) + const docs = await MongoManager.getProjectsDocs(projectId, {}, { _id: 1 }) const docIds = docs.map(doc => doc._id) for (const docId of docIds) { const doc = await DocManager.peekDoc(projectId, docId) @@ -247,7 +244,7 @@ const DocManager = { } modified = true - await MongoManager.promises.upsertIntoDocCollection( + await MongoManager.upsertIntoDocCollection( projectId, docId, doc?.rev, @@ -262,11 +259,7 @@ const DocManager = { async patchDoc(projectId, docId, meta) { const projection = { _id: 1, deleted: true } - const doc = await MongoManager.promises.findDoc( - projectId, - docId, - projection - ) + const doc = await MongoManager.findDoc(projectId, docId, projection) if (!doc) { throw new Errors.NotFoundError( `No such project/doc to delete: ${projectId}/${docId}` @@ -275,7 +268,7 @@ const DocManager = { if (meta.deleted && Settings.docstore.archiveOnSoftDelete) { // The user will not read this doc anytime soon. Flush it out of mongo. - DocArchive.promises.archiveDoc(projectId, docId).catch(err => { + DocArchive.archiveDoc(projectId, docId).catch(err => { logger.warn( { projectId, docId, err }, 'archiving a single doc in the background failed' @@ -283,15 +276,8 @@ const DocManager = { }) } - await MongoManager.promises.patchDoc(projectId, docId, meta) + await MongoManager.patchDoc(projectId, docId, meta) }, } -module.exports = { - ...callbackifyAll(DocManager, { - multiResult: { - updateDoc: ['modified', 'rev'], - }, - }), - promises: DocManager, -} +module.exports = DocManager diff --git a/services/docstore/app/js/Errors.js b/services/docstore/app/js/Errors.js index bbdbe75c08..7b150cc0db 100644 --- a/services/docstore/app/js/Errors.js +++ b/services/docstore/app/js/Errors.js @@ -10,10 +10,13 @@ class DocRevValueError extends OError {} class DocVersionDecrementedError extends OError {} +class DocWithoutLinesError extends OError {} + module.exports = { Md5MismatchError, DocModifiedError, DocRevValueError, DocVersionDecrementedError, + DocWithoutLinesError, ...Errors, } diff --git a/services/docstore/app/js/HealthChecker.js b/services/docstore/app/js/HealthChecker.js index 34cd5c973c..a5b7ad7e9a 100644 --- a/services/docstore/app/js/HealthChecker.js +++ b/services/docstore/app/js/HealthChecker.js @@ -1,67 +1,35 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ const { db, ObjectId } = require('./mongodb') -const request = require('request') -const async = require('async') const _ = require('lodash') const crypto = require('node:crypto') const settings = require('@overleaf/settings') const { port } = settings.internal.docstore const logger = require('@overleaf/logger') +const { fetchNothing, fetchJson } = require('@overleaf/fetch-utils') -module.exports = { - check(callback) { - const docId = new ObjectId() - const projectId = new ObjectId(settings.docstore.healthCheck.project_id) - const url = `http://127.0.0.1:${port}/project/${projectId}/doc/${docId}` - const lines = [ - 'smoke test - delete me', - `${crypto.randomBytes(32).toString('hex')}`, - ] - const getOpts = () => ({ - url, - timeout: 3000, +async function check() { + const docId = new ObjectId() + const projectId = new ObjectId(settings.docstore.healthCheck.project_id) + const url = `http://127.0.0.1:${port}/project/${projectId}/doc/${docId}` + const lines = [ + 'smoke test - delete me', + `${crypto.randomBytes(32).toString('hex')}`, + ] + logger.debug({ lines, url, docId, projectId }, 'running health check') + let body + try { + await fetchNothing(url, { + method: 'POST', + json: { lines, version: 42, ranges: {} }, + signal: AbortSignal.timeout(3_000), }) - logger.debug({ lines, url, docId, projectId }, 'running health check') - const jobs = [ - function (cb) { - const opts = getOpts() - opts.json = { lines, version: 42, ranges: {} } - return request.post(opts, cb) - }, - function (cb) { - const opts = getOpts() - opts.json = true - return request.get(opts, function (err, res, body) { - if (err != null) { - logger.err({ err }, 'docstore returned a error in health check get') - return cb(err) - } else if (res == null) { - return cb(new Error('no response from docstore with get check')) - } else if ((res != null ? res.statusCode : undefined) !== 200) { - return cb(new Error(`status code not 200, its ${res.statusCode}`)) - } else if ( - _.isEqual(body != null ? body.lines : undefined, lines) && - (body != null ? body._id : undefined) === docId.toString() - ) { - return cb() - } else { - return cb( - new Error( - `health check lines not equal ${body.lines} != ${lines}` - ) - ) - } - }) - }, - cb => db.docs.deleteOne({ _id: docId, project_id: projectId }, cb), - ] - return async.series(jobs, callback) - }, + body = await fetchJson(url, { signal: AbortSignal.timeout(3_000) }) + } finally { + await db.docs.deleteOne({ _id: docId, project_id: projectId }) + } + if (!_.isEqual(body?.lines, lines)) { + throw new Error(`health check lines not equal ${body.lines} != ${lines}`) + } +} +module.exports = { + check, } diff --git a/services/docstore/app/js/HttpController.js b/services/docstore/app/js/HttpController.js index 1c4e137033..895e8e8e7b 100644 --- a/services/docstore/app/js/HttpController.js +++ b/services/docstore/app/js/HttpController.js @@ -4,143 +4,92 @@ const DocArchive = require('./DocArchiveManager') const HealthChecker = require('./HealthChecker') const Errors = require('./Errors') const Settings = require('@overleaf/settings') +const { expressify } = require('@overleaf/promise-utils') -function getDoc(req, res, next) { +async function getDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params const includeDeleted = req.query.include_deleted === 'true' logger.debug({ projectId, docId }, 'getting doc') - DocManager.getFullDoc(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - logger.debug({ docId, projectId }, 'got doc') - if (doc == null) { - res.sendStatus(404) - } else if (doc.deleted && !includeDeleted) { - res.sendStatus(404) - } else { - res.json(_buildDocView(doc)) - } - }) + const doc = await DocManager.getFullDoc(projectId, docId) + logger.debug({ docId, projectId }, 'got doc') + if (doc.deleted && !includeDeleted) { + res.sendStatus(404) + } else { + res.json(_buildDocView(doc)) + } } -function peekDoc(req, res, next) { +async function peekDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'peeking doc') - DocManager.peekDoc(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - if (doc == null) { - res.sendStatus(404) - } else { - res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active') - res.json(_buildDocView(doc)) - } - }) + const doc = await DocManager.peekDoc(projectId, docId) + res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active') + res.json(_buildDocView(doc)) } -function isDocDeleted(req, res, next) { +async function isDocDeleted(req, res) { const { doc_id: docId, project_id: projectId } = req.params - DocManager.isDocDeleted(projectId, docId, function (error, deleted) { - if (error) { - return next(error) - } - res.json({ deleted }) - }) + const deleted = await DocManager.isDocDeleted(projectId, docId) + res.json({ deleted }) } -function getRawDoc(req, res, next) { +async function getRawDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'getting raw doc') - DocManager.getDocLines(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - if (doc == null) { - res.sendStatus(404) - } else { - res.setHeader('content-type', 'text/plain') - res.send(_buildRawDocView(doc)) - } - }) + const content = await DocManager.getDocLines(projectId, docId) + res.setHeader('content-type', 'text/plain') + res.send(content) } -function getAllDocs(req, res, next) { +async function getAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all docs') - DocManager.getAllNonDeletedDocs( - projectId, - { lines: true, rev: true }, - function (error, docs) { - if (docs == null) { - docs = [] - } - if (error) { - return next(error) - } - const docViews = _buildDocsArrayView(projectId, docs) - for (const docView of docViews) { - if (!docView.lines) { - logger.warn({ projectId, docId: docView._id }, 'missing doc lines') - docView.lines = [] - } - } - res.json(docViews) + const docs = await DocManager.getAllNonDeletedDocs(projectId, { + lines: true, + rev: true, + }) + const docViews = _buildDocsArrayView(projectId, docs) + for (const docView of docViews) { + if (!docView.lines) { + logger.warn({ projectId, docId: docView._id }, 'missing doc lines') + docView.lines = [] } - ) + } + res.json(docViews) } -function getAllDeletedDocs(req, res, next) { +async function getAllDeletedDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all deleted docs') - DocManager.getAllDeletedDocs( - projectId, - { name: true, deletedAt: true }, - function (error, docs) { - if (error) { - return next(error) - } - res.json( - docs.map(doc => ({ - _id: doc._id.toString(), - name: doc.name, - deletedAt: doc.deletedAt, - })) - ) - } + const docs = await DocManager.getAllDeletedDocs(projectId, { + name: true, + deletedAt: true, + }) + res.json( + docs.map(doc => ({ + _id: doc._id.toString(), + name: doc.name, + deletedAt: doc.deletedAt, + })) ) } -function getAllRanges(req, res, next) { +async function getAllRanges(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all ranges') - DocManager.getAllNonDeletedDocs( - projectId, - { ranges: true }, - function (error, docs) { - if (docs == null) { - docs = [] - } - if (error) { - return next(error) - } - res.json(_buildDocsArrayView(projectId, docs)) - } - ) -} - -function projectHasRanges(req, res, next) { - const { project_id: projectId } = req.params - DocManager.projectHasRanges(projectId, (err, projectHasRanges) => { - if (err) { - return next(err) - } - res.json({ projectHasRanges }) + const docs = await DocManager.getAllNonDeletedDocs(projectId, { + ranges: true, }) + res.json(_buildDocsArrayView(projectId, docs)) } -function updateDoc(req, res, next) { +async function projectHasRanges(req, res) { + const { project_id: projectId } = req.params + const projectHasRanges = await DocManager.projectHasRanges(projectId) + res.json({ projectHasRanges }) +} + +async function updateDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params const lines = req.body?.lines const version = req.body?.version @@ -172,25 +121,20 @@ function updateDoc(req, res, next) { } logger.debug({ projectId, docId }, 'got http request to update doc') - DocManager.updateDoc( + const { modified, rev } = await DocManager.updateDoc( projectId, docId, lines, version, - ranges, - function (error, modified, rev) { - if (error) { - return next(error) - } - res.json({ - modified, - rev, - }) - } + ranges ) + res.json({ + modified, + rev, + }) } -function patchDoc(req, res, next) { +async function patchDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'patching doc') @@ -203,12 +147,8 @@ function patchDoc(req, res, next) { logger.fatal({ field }, 'joi validation for pathDoc is broken') } }) - DocManager.patchDoc(projectId, docId, meta, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocManager.patchDoc(projectId, docId, meta) + res.sendStatus(204) } function _buildDocView(doc) { @@ -221,10 +161,6 @@ function _buildDocView(doc) { return docView } -function _buildRawDocView(doc) { - return (doc?.lines ?? []).join('\n') -} - function _buildDocsArrayView(projectId, docs) { const docViews = [] for (const doc of docs) { @@ -241,79 +177,67 @@ function _buildDocsArrayView(projectId, docs) { return docViews } -function archiveAllDocs(req, res, next) { +async function archiveAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'archiving all docs') - DocArchive.archiveAllDocs(projectId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.archiveAllDocs(projectId) + res.sendStatus(204) } -function archiveDoc(req, res, next) { +async function archiveDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'archiving a doc') - DocArchive.archiveDoc(projectId, docId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.archiveDoc(projectId, docId) + res.sendStatus(204) } -function unArchiveAllDocs(req, res, next) { +async function unArchiveAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'unarchiving all docs') - DocArchive.unArchiveAllDocs(projectId, function (err) { - if (err) { - if (err instanceof Errors.DocRevValueError) { - logger.warn({ err }, 'Failed to unarchive doc') - return res.sendStatus(409) - } - return next(err) + try { + await DocArchive.unArchiveAllDocs(projectId) + } catch (err) { + if (err instanceof Errors.DocRevValueError) { + logger.warn({ err }, 'Failed to unarchive doc') + return res.sendStatus(409) } - res.sendStatus(200) - }) + throw err + } + res.sendStatus(200) } -function destroyProject(req, res, next) { +async function destroyProject(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'destroying all docs') - DocArchive.destroyProject(projectId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.destroyProject(projectId) + res.sendStatus(204) } -function healthCheck(req, res) { - HealthChecker.check(function (err) { - if (err) { - logger.err({ err }, 'error performing health check') - res.sendStatus(500) - } else { - res.sendStatus(200) - } - }) +async function healthCheck(req, res) { + try { + await HealthChecker.check() + } catch (err) { + logger.err({ err }, 'error performing health check') + res.sendStatus(500) + return + } + res.sendStatus(200) } module.exports = { - getDoc, - peekDoc, - isDocDeleted, - getRawDoc, - getAllDocs, - getAllDeletedDocs, - getAllRanges, - projectHasRanges, - updateDoc, - patchDoc, - archiveAllDocs, - archiveDoc, - unArchiveAllDocs, - destroyProject, - healthCheck, + getDoc: expressify(getDoc), + peekDoc: expressify(peekDoc), + isDocDeleted: expressify(isDocDeleted), + getRawDoc: expressify(getRawDoc), + getAllDocs: expressify(getAllDocs), + getAllDeletedDocs: expressify(getAllDeletedDocs), + getAllRanges: expressify(getAllRanges), + projectHasRanges: expressify(projectHasRanges), + updateDoc: expressify(updateDoc), + patchDoc: expressify(patchDoc), + archiveAllDocs: expressify(archiveAllDocs), + archiveDoc: expressify(archiveDoc), + unArchiveAllDocs: expressify(unArchiveAllDocs), + destroyProject: expressify(destroyProject), + healthCheck: expressify(healthCheck), } diff --git a/services/docstore/app/js/MongoManager.js b/services/docstore/app/js/MongoManager.js index ad1a2d2b40..ef101f91c0 100644 --- a/services/docstore/app/js/MongoManager.js +++ b/services/docstore/app/js/MongoManager.js @@ -1,7 +1,6 @@ const { db, ObjectId } = require('./mongodb') const Settings = require('@overleaf/settings') const Errors = require('./Errors') -const { callbackify } = require('node:util') const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs @@ -241,34 +240,17 @@ async function destroyProject(projectId) { } module.exports = { - findDoc: callbackify(findDoc), - getProjectsDeletedDocs: callbackify(getProjectsDeletedDocs), - getProjectsDocs: callbackify(getProjectsDocs), - getArchivedProjectDocs: callbackify(getArchivedProjectDocs), - getNonArchivedProjectDocIds: callbackify(getNonArchivedProjectDocIds), - getNonDeletedArchivedProjectDocs: callbackify( - getNonDeletedArchivedProjectDocs - ), - upsertIntoDocCollection: callbackify(upsertIntoDocCollection), - restoreArchivedDoc: callbackify(restoreArchivedDoc), - patchDoc: callbackify(patchDoc), - getDocForArchiving: callbackify(getDocForArchiving), - markDocAsArchived: callbackify(markDocAsArchived), - checkRevUnchanged: callbackify(checkRevUnchanged), - destroyProject: callbackify(destroyProject), - promises: { - findDoc, - getProjectsDeletedDocs, - getProjectsDocs, - getArchivedProjectDocs, - getNonArchivedProjectDocIds, - getNonDeletedArchivedProjectDocs, - upsertIntoDocCollection, - restoreArchivedDoc, - patchDoc, - getDocForArchiving, - markDocAsArchived, - checkRevUnchanged, - destroyProject, - }, + findDoc, + getProjectsDeletedDocs, + getProjectsDocs, + getArchivedProjectDocs, + getNonArchivedProjectDocIds, + getNonDeletedArchivedProjectDocs, + upsertIntoDocCollection, + restoreArchivedDoc, + patchDoc, + getDocForArchiving, + markDocAsArchived, + checkRevUnchanged, + destroyProject, } diff --git a/services/docstore/app/js/StreamToBuffer.js b/services/docstore/app/js/StreamToBuffer.js index 7de146cd11..09215a7367 100644 --- a/services/docstore/app/js/StreamToBuffer.js +++ b/services/docstore/app/js/StreamToBuffer.js @@ -2,13 +2,9 @@ const { LoggerStream, WritableBuffer } = require('@overleaf/stream-utils') const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger/logging-manager') const { pipeline } = require('node:stream/promises') -const { callbackify } = require('node:util') module.exports = { - streamToBuffer: callbackify(streamToBuffer), - promises: { - streamToBuffer, - }, + streamToBuffer, } async function streamToBuffer(projectId, docId, stream) { diff --git a/services/docstore/package.json b/services/docstore/package.json index e505f731d3..bf5857fd49 100644 --- a/services/docstore/package.json +++ b/services/docstore/package.json @@ -17,6 +17,7 @@ "types:check": "tsc --noEmit" }, "dependencies": { + "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/docstore/test/acceptance/js/HealthCheckerTest.js b/services/docstore/test/acceptance/js/HealthCheckerTest.js new file mode 100644 index 0000000000..b25a45312b --- /dev/null +++ b/services/docstore/test/acceptance/js/HealthCheckerTest.js @@ -0,0 +1,28 @@ +const { db } = require('../../../app/js/mongodb') +const DocstoreApp = require('./helpers/DocstoreApp') +const DocstoreClient = require('./helpers/DocstoreClient') +const { expect } = require('chai') + +describe('HealthChecker', function () { + beforeEach('start', function (done) { + DocstoreApp.ensureRunning(done) + }) + beforeEach('clear docs collection', async function () { + await db.docs.deleteMany({}) + }) + let res + beforeEach('run health check', function (done) { + DocstoreClient.healthCheck((err, _res) => { + res = _res + done(err) + }) + }) + + it('should return 200', function () { + res.statusCode.should.equal(200) + }) + + it('should not leave any cruft behind', async function () { + expect(await db.docs.find({}).toArray()).to.deep.equal([]) + }) +}) diff --git a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js index 790ec8f237..d8fe94829b 100644 --- a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js +++ b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js @@ -181,6 +181,13 @@ module.exports = DocstoreClient = { ) }, + healthCheck(callback) { + request.get( + `http://127.0.0.1:${settings.internal.docstore.port}/health_check`, + callback + ) + }, + getS3Doc(projectId, docId, callback) { getStringFromPersistor( Persistor, diff --git a/services/docstore/test/unit/js/DocArchiveManagerTests.js b/services/docstore/test/unit/js/DocArchiveManagerTests.js index a57f9806c8..fbc1667314 100644 --- a/services/docstore/test/unit/js/DocArchiveManagerTests.js +++ b/services/docstore/test/unit/js/DocArchiveManagerTests.js @@ -4,7 +4,7 @@ const modulePath = '../../../app/js/DocArchiveManager.js' const SandboxedModule = require('sandboxed-module') const { ObjectId } = require('mongodb-legacy') const Errors = require('../../../app/js/Errors') -const StreamToBuffer = require('../../../app/js/StreamToBuffer').promises +const StreamToBuffer = require('../../../app/js/StreamToBuffer') describe('DocArchiveManager', function () { let DocArchiveManager, @@ -142,37 +142,33 @@ describe('DocArchiveManager', function () { } MongoManager = { - promises: { - markDocAsArchived: sinon.stub().resolves(), - restoreArchivedDoc: sinon.stub().resolves(), - upsertIntoDocCollection: sinon.stub().resolves(), - getProjectsDocs: sinon.stub().resolves(mongoDocs), - getNonDeletedArchivedProjectDocs: getArchivedProjectDocs, - getNonArchivedProjectDocIds, - getArchivedProjectDocs, - findDoc: sinon.stub().callsFake(fakeGetDoc), - getDocForArchiving: sinon.stub().callsFake(fakeGetDoc), - destroyProject: sinon.stub().resolves(), - }, + markDocAsArchived: sinon.stub().resolves(), + restoreArchivedDoc: sinon.stub().resolves(), + upsertIntoDocCollection: sinon.stub().resolves(), + getProjectsDocs: sinon.stub().resolves(mongoDocs), + getNonDeletedArchivedProjectDocs: getArchivedProjectDocs, + getNonArchivedProjectDocIds, + getArchivedProjectDocs, + findDoc: sinon.stub().callsFake(fakeGetDoc), + getDocForArchiving: sinon.stub().callsFake(fakeGetDoc), + destroyProject: sinon.stub().resolves(), } // Wrap streamToBuffer so that we can pass in something that it expects (in // this case, a Promise) rather than a stubbed stream object streamToBuffer = { - promises: { - streamToBuffer: async () => { - const inputStream = new Promise(resolve => { - stream.on('data', data => resolve(data)) - }) + streamToBuffer: async () => { + const inputStream = new Promise(resolve => { + stream.on('data', data => resolve(data)) + }) - const value = await StreamToBuffer.streamToBuffer( - 'testProjectId', - 'testDocId', - inputStream - ) + const value = await StreamToBuffer.streamToBuffer( + 'testProjectId', + 'testDocId', + inputStream + ) - return value - }, + return value }, } @@ -192,9 +188,8 @@ describe('DocArchiveManager', function () { describe('archiveDoc', function () { it('should resolve when passed a valid document', async function () { - await expect( - DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - ).to.eventually.be.fulfilled + await expect(DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)).to + .eventually.be.fulfilled }) it('should throw an error if the doc has no lines', async function () { @@ -202,26 +197,26 @@ describe('DocArchiveManager', function () { doc.lines = null await expect( - DocArchiveManager.promises.archiveDoc(projectId, doc._id) + DocArchiveManager.archiveDoc(projectId, doc._id) ).to.eventually.be.rejectedWith('doc has no lines') }) it('should add the schema version', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[1]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id) expect(StreamUtils.ReadableString).to.have.been.calledWith( sinon.match(/"schema_v":1/) ) }) it('should calculate the hex md5 sum of the content', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(Crypto.createHash).to.have.been.calledWith('md5') expect(HashUpdate).to.have.been.calledWith(archivedDocJson) expect(HashDigest).to.have.been.calledWith('hex') }) it('should pass the md5 hash to the object persistor for verification', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(PersistorManager.sendStream).to.have.been.calledWith( sinon.match.any, @@ -232,7 +227,7 @@ describe('DocArchiveManager', function () { }) it('should pass the correct bucket and key to the persistor', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(PersistorManager.sendStream).to.have.been.calledWith( Settings.docstore.bucket, @@ -241,7 +236,7 @@ describe('DocArchiveManager', function () { }) it('should create a stream from the encoded json and send it', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(StreamUtils.ReadableString).to.have.been.calledWith( archivedDocJson ) @@ -253,8 +248,8 @@ describe('DocArchiveManager', function () { }) it('should mark the doc as archived', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[0]._id, mongoDocs[0].rev @@ -267,8 +262,8 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getDocForArchiving).to.not.have.been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getDocForArchiving).to.not.have.been.called }) }) @@ -285,7 +280,7 @@ describe('DocArchiveManager', function () { it('should return an error', async function () { await expect( - DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) ).to.eventually.be.rejectedWith('null bytes detected') }) }) @@ -296,21 +291,19 @@ describe('DocArchiveManager', function () { describe('when the doc is in S3', function () { beforeEach(function () { - MongoManager.promises.findDoc = sinon - .stub() - .resolves({ inS3: true, rev }) + MongoManager.findDoc = sinon.stub().resolves({ inS3: true, rev }) docId = mongoDocs[0]._id lines = ['doc', 'lines'] rev = 123 }) it('should resolve when passed a valid document', async function () { - await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId)) - .to.eventually.be.fulfilled + await expect(DocArchiveManager.unarchiveDoc(projectId, docId)).to + .eventually.be.fulfilled }) it('should test md5 validity with the raw buffer', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(HashUpdate).to.have.been.calledWith( sinon.match.instanceOf(Buffer) ) @@ -319,15 +312,17 @@ describe('DocArchiveManager', function () { it('should throw an error if the md5 does not match', async function () { PersistorManager.getObjectMd5Hash.resolves('badf00d') await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError) }) it('should restore the doc in Mongo', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, archivedDoc) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + archivedDoc + ) }) describe('when archiving is not configured', function () { @@ -337,15 +332,15 @@ describe('DocArchiveManager', function () { it('should error out on archived doc', async function () { await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.match( /found archived doc, but archiving backend is not configured/ ) }) it('should return early on non-archived doc', async function () { - MongoManager.promises.findDoc = sinon.stub().resolves({ rev }) - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + MongoManager.findDoc = sinon.stub().resolves({ rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(PersistorManager.getObjectMd5Hash).to.not.have.been.called }) }) @@ -363,10 +358,12 @@ describe('DocArchiveManager', function () { }) it('should return the docs lines', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev } + ) }) }) @@ -385,14 +382,16 @@ describe('DocArchiveManager', function () { }) it('should return the doc lines and ranges', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { - lines, - ranges: { mongo: 'ranges' }, - rev: 456, - }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { + lines, + ranges: { mongo: 'ranges' }, + rev: 456, + } + ) }) }) @@ -406,10 +405,12 @@ describe('DocArchiveManager', function () { }) it('should return only the doc lines', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev: 456 }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev: 456 } + ) }) }) @@ -423,10 +424,12 @@ describe('DocArchiveManager', function () { }) it('should use the rev obtained from Mongo', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev } + ) }) }) @@ -441,7 +444,7 @@ describe('DocArchiveManager', function () { it('should throw an error', async function () { await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejectedWith( "I don't understand the doc format in s3" ) @@ -451,8 +454,8 @@ describe('DocArchiveManager', function () { }) it('should not do anything if the file is already unarchived', async function () { - MongoManager.promises.findDoc.resolves({ inS3: false }) - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + MongoManager.findDoc.resolves({ inS3: false }) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(PersistorManager.getObjectStream).not.to.have.been.called }) @@ -461,7 +464,7 @@ describe('DocArchiveManager', function () { .stub() .rejects(new Errors.NotFoundError()) await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError) }) }) @@ -469,13 +472,11 @@ describe('DocArchiveManager', function () { describe('destroyProject', function () { describe('when archiving is enabled', function () { beforeEach(async function () { - await DocArchiveManager.promises.destroyProject(projectId) + await DocArchiveManager.destroyProject(projectId) }) it('should delete the project in Mongo', function () { - expect(MongoManager.promises.destroyProject).to.have.been.calledWith( - projectId - ) + expect(MongoManager.destroyProject).to.have.been.calledWith(projectId) }) it('should delete the project in the persistor', function () { @@ -489,13 +490,11 @@ describe('DocArchiveManager', function () { describe('when archiving is disabled', function () { beforeEach(async function () { Settings.docstore.backend = '' - await DocArchiveManager.promises.destroyProject(projectId) + await DocArchiveManager.destroyProject(projectId) }) it('should delete the project in Mongo', function () { - expect(MongoManager.promises.destroyProject).to.have.been.calledWith( - projectId - ) + expect(MongoManager.destroyProject).to.have.been.calledWith(projectId) }) it('should not delete the project in the persistor', function () { @@ -506,33 +505,35 @@ describe('DocArchiveManager', function () { describe('archiveAllDocs', function () { it('should resolve with valid arguments', async function () { - await expect(DocArchiveManager.promises.archiveAllDocs(projectId)).to - .eventually.be.fulfilled + await expect(DocArchiveManager.archiveAllDocs(projectId)).to.eventually.be + .fulfilled }) it('should archive all project docs which are not in s3', async function () { - await DocArchiveManager.promises.archiveAllDocs(projectId) + await DocArchiveManager.archiveAllDocs(projectId) // not inS3 - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[0]._id ) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[1]._id ) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[4]._id ) // inS3 - expect( - MongoManager.promises.markDocAsArchived - ).not.to.have.been.calledWith(projectId, mongoDocs[2]._id) - expect( - MongoManager.promises.markDocAsArchived - ).not.to.have.been.calledWith(projectId, mongoDocs[3]._id) + expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith( + projectId, + mongoDocs[2]._id + ) + expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith( + projectId, + mongoDocs[3]._id + ) }) describe('when archiving is not configured', function () { @@ -541,21 +542,20 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getNonArchivedProjectDocIds).to.not.have - .been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getNonArchivedProjectDocIds).to.not.have.been.called }) }) }) describe('unArchiveAllDocs', function () { it('should resolve with valid arguments', async function () { - await expect(DocArchiveManager.promises.unArchiveAllDocs(projectId)).to - .eventually.be.fulfilled + await expect(DocArchiveManager.unArchiveAllDocs(projectId)).to.eventually + .be.fulfilled }) it('should unarchive all inS3 docs', async function () { - await DocArchiveManager.promises.unArchiveAllDocs(projectId) + await DocArchiveManager.unArchiveAllDocs(projectId) for (const doc of archivedDocs) { expect(PersistorManager.getObjectStream).to.have.been.calledWith( @@ -571,9 +571,9 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getNonDeletedArchivedProjectDocs).to.not - .have.been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getNonDeletedArchivedProjectDocs).to.not.have.been + .called }) }) }) diff --git a/services/docstore/test/unit/js/DocManagerTests.js b/services/docstore/test/unit/js/DocManagerTests.js index 8405520e6e..f207f8e993 100644 --- a/services/docstore/test/unit/js/DocManagerTests.js +++ b/services/docstore/test/unit/js/DocManagerTests.js @@ -17,19 +17,15 @@ describe('DocManager', function () { this.version = 42 this.MongoManager = { - promises: { - findDoc: sinon.stub(), - getProjectsDocs: sinon.stub(), - patchDoc: sinon.stub().resolves(), - upsertIntoDocCollection: sinon.stub().resolves(), - }, + findDoc: sinon.stub(), + getProjectsDocs: sinon.stub(), + patchDoc: sinon.stub().resolves(), + upsertIntoDocCollection: sinon.stub().resolves(), } this.DocArchiveManager = { - promises: { - unarchiveDoc: sinon.stub(), - unArchiveAllDocs: sinon.stub(), - archiveDoc: sinon.stub().resolves(), - }, + unarchiveDoc: sinon.stub(), + unArchiveAllDocs: sinon.stub(), + archiveDoc: sinon.stub().resolves(), } this.RangeManager = { jsonRangesToMongo(r) { @@ -52,7 +48,7 @@ describe('DocManager', function () { describe('getFullDoc', function () { beforeEach(function () { - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() this.doc = { _id: this.doc_id, lines: ['2134'], @@ -60,13 +56,10 @@ describe('DocManager', function () { }) it('should call get doc with a quick filter', async function () { - this.DocManager.promises._getDoc.resolves(this.doc) - const doc = await this.DocManager.promises.getFullDoc( - this.project_id, - this.doc_id - ) + this.DocManager._getDoc.resolves(this.doc) + const doc = await this.DocManager.getFullDoc(this.project_id, this.doc_id) doc.should.equal(this.doc) - this.DocManager.promises._getDoc + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { lines: true, rev: true, @@ -79,27 +72,27 @@ describe('DocManager', function () { }) it('should return error when get doc errors', async function () { - this.DocManager.promises._getDoc.rejects(this.stubbedError) + this.DocManager._getDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises.getFullDoc(this.project_id, this.doc_id) + this.DocManager.getFullDoc(this.project_id, this.doc_id) ).to.be.rejectedWith(this.stubbedError) }) }) describe('getRawDoc', function () { beforeEach(function () { - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() this.doc = { lines: ['2134'] } }) it('should call get doc with a quick filter', async function () { - this.DocManager.promises._getDoc.resolves(this.doc) - const doc = await this.DocManager.promises.getDocLines( + this.DocManager._getDoc.resolves(this.doc) + const content = await this.DocManager.getDocLines( this.project_id, this.doc_id ) - doc.should.equal(this.doc) - this.DocManager.promises._getDoc + content.should.equal(this.doc.lines.join('\n')) + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { lines: true, inS3: true, @@ -108,11 +101,25 @@ describe('DocManager', function () { }) it('should return error when get doc errors', async function () { - this.DocManager.promises._getDoc.rejects(this.stubbedError) + this.DocManager._getDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises.getDocLines(this.project_id, this.doc_id) + this.DocManager.getDocLines(this.project_id, this.doc_id) ).to.be.rejectedWith(this.stubbedError) }) + + it('should return error when get doc does not exist', async function () { + this.DocManager._getDoc.resolves(null) + await expect( + this.DocManager.getDocLines(this.project_id, this.doc_id) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + + it('should return error when get doc has no lines', async function () { + this.DocManager._getDoc.resolves({}) + await expect( + this.DocManager.getDocLines(this.project_id, this.doc_id) + ).to.be.rejectedWith(Errors.DocWithoutLinesError) + }) }) describe('getDoc', function () { @@ -128,26 +135,25 @@ describe('DocManager', function () { describe('when using a filter', function () { beforeEach(function () { - this.MongoManager.promises.findDoc.resolves(this.doc) + this.MongoManager.findDoc.resolves(this.doc) }) it('should error if inS3 is not set to true', async function () { await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { inS3: false, }) ).to.be.rejected }) it('should always get inS3 even when no filter is passed', async function () { - await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id) - ).to.be.rejected - this.MongoManager.promises.findDoc.called.should.equal(false) + await expect(this.DocManager._getDoc(this.project_id, this.doc_id)).to + .be.rejected + this.MongoManager.findDoc.called.should.equal(false) }) it('should not error if inS3 is set to true', async function () { - await this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + await this.DocManager._getDoc(this.project_id, this.doc_id, { inS3: true, }) }) @@ -155,8 +161,8 @@ describe('DocManager', function () { describe('when the doc is in the doc collection', function () { beforeEach(async function () { - this.MongoManager.promises.findDoc.resolves(this.doc) - this.result = await this.DocManager.promises._getDoc( + this.MongoManager.findDoc.resolves(this.doc) + this.result = await this.DocManager._getDoc( this.project_id, this.doc_id, { version: true, inS3: true } @@ -164,7 +170,7 @@ describe('DocManager', function () { }) it('should get the doc from the doc collection', function () { - this.MongoManager.promises.findDoc + this.MongoManager.findDoc .calledWith(this.project_id, this.doc_id) .should.equal(true) }) @@ -177,9 +183,9 @@ describe('DocManager', function () { describe('when MongoManager.findDoc errors', function () { it('should return the error', async function () { - this.MongoManager.promises.findDoc.rejects(this.stubbedError) + this.MongoManager.findDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { version: true, inS3: true, }) @@ -202,15 +208,15 @@ describe('DocManager', function () { version: 2, inS3: false, } - this.MongoManager.promises.findDoc.resolves(this.doc) - this.DocArchiveManager.promises.unarchiveDoc.callsFake( + this.MongoManager.findDoc.resolves(this.doc) + this.DocArchiveManager.unarchiveDoc.callsFake( async (projectId, docId) => { - this.MongoManager.promises.findDoc.resolves({ + this.MongoManager.findDoc.resolves({ ...this.unarchivedDoc, }) } ) - this.result = await this.DocManager.promises._getDoc( + this.result = await this.DocManager._getDoc( this.project_id, this.doc_id, { @@ -221,13 +227,13 @@ describe('DocManager', function () { }) it('should call the DocArchive to unarchive the doc', function () { - this.DocArchiveManager.promises.unarchiveDoc + this.DocArchiveManager.unarchiveDoc .calledWith(this.project_id, this.doc_id) .should.equal(true) }) it('should look up the doc twice', function () { - this.MongoManager.promises.findDoc.calledTwice.should.equal(true) + this.MongoManager.findDoc.calledTwice.should.equal(true) }) it('should return the doc', function () { @@ -239,9 +245,9 @@ describe('DocManager', function () { describe('when the doc does not exist in the docs collection', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.findDoc.resolves(null) + this.MongoManager.findDoc.resolves(null) await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { version: true, inS3: true, }) @@ -262,17 +268,17 @@ describe('DocManager', function () { lines: ['mock-lines'], }, ] - this.MongoManager.promises.getProjectsDocs.resolves(this.docs) - this.DocArchiveManager.promises.unArchiveAllDocs.resolves(this.docs) + this.MongoManager.getProjectsDocs.resolves(this.docs) + this.DocArchiveManager.unArchiveAllDocs.resolves(this.docs) this.filter = { lines: true } - this.result = await this.DocManager.promises.getAllNonDeletedDocs( + this.result = await this.DocManager.getAllNonDeletedDocs( this.project_id, this.filter ) }) it('should get the project from the database', function () { - this.MongoManager.promises.getProjectsDocs.should.have.been.calledWith( + this.MongoManager.getProjectsDocs.should.have.been.calledWith( this.project_id, { include_deleted: false }, this.filter @@ -286,13 +292,10 @@ describe('DocManager', function () { describe('when there are no docs for the project', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.getProjectsDocs.resolves(null) - this.DocArchiveManager.promises.unArchiveAllDocs.resolves(null) + this.MongoManager.getProjectsDocs.resolves(null) + this.DocArchiveManager.unArchiveAllDocs.resolves(null) await expect( - this.DocManager.promises.getAllNonDeletedDocs( - this.project_id, - this.filter - ) + this.DocManager.getAllNonDeletedDocs(this.project_id, this.filter) ).to.be.rejectedWith(`No docs for project ${this.project_id}`) }) }) @@ -303,7 +306,7 @@ describe('DocManager', function () { beforeEach(function () { this.lines = ['mock', 'doc', 'lines'] this.rev = 77 - this.MongoManager.promises.findDoc.resolves({ + this.MongoManager.findDoc.resolves({ _id: new ObjectId(this.doc_id), }) this.meta = {} @@ -311,7 +314,7 @@ describe('DocManager', function () { describe('standard path', function () { beforeEach(async function () { - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -319,14 +322,14 @@ describe('DocManager', function () { }) it('should get the doc', function () { - expect(this.MongoManager.promises.findDoc).to.have.been.calledWith( + expect(this.MongoManager.findDoc).to.have.been.calledWith( this.project_id, this.doc_id ) }) it('should persist the meta', function () { - expect(this.MongoManager.promises.patchDoc).to.have.been.calledWith( + expect(this.MongoManager.patchDoc).to.have.been.calledWith( this.project_id, this.doc_id, this.meta @@ -339,7 +342,7 @@ describe('DocManager', function () { this.settings.docstore.archiveOnSoftDelete = false this.meta.deleted = true - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -347,8 +350,7 @@ describe('DocManager', function () { }) it('should not flush the doc out of mongo', function () { - expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been - .called + expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called }) }) @@ -356,7 +358,7 @@ describe('DocManager', function () { beforeEach(async function () { this.settings.docstore.archiveOnSoftDelete = false this.meta.deleted = false - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -364,8 +366,7 @@ describe('DocManager', function () { }) it('should not flush the doc out of mongo', function () { - expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been - .called + expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called }) }) @@ -377,7 +378,7 @@ describe('DocManager', function () { describe('when the background flush succeeds', function () { beforeEach(async function () { - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -389,17 +390,18 @@ describe('DocManager', function () { }) it('should flush the doc out of mongo', function () { - expect( - this.DocArchiveManager.promises.archiveDoc - ).to.have.been.calledWith(this.project_id, this.doc_id) + expect(this.DocArchiveManager.archiveDoc).to.have.been.calledWith( + this.project_id, + this.doc_id + ) }) }) describe('when the background flush fails', function () { beforeEach(async function () { this.err = new Error('foo') - this.DocArchiveManager.promises.archiveDoc.rejects(this.err) - await this.DocManager.promises.patchDoc( + this.DocArchiveManager.archiveDoc.rejects(this.err) + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -422,9 +424,9 @@ describe('DocManager', function () { describe('when the doc does not exist', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.findDoc.resolves(null) + this.MongoManager.findDoc.resolves(null) await expect( - this.DocManager.promises.patchDoc(this.project_id, this.doc_id, {}) + this.DocManager.patchDoc(this.project_id, this.doc_id, {}) ).to.be.rejectedWith( `No such project/doc to delete: ${this.project_id}/${this.doc_id}` ) @@ -470,13 +472,13 @@ describe('DocManager', function () { ranges: this.originalRanges, } - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() }) describe('when only the doc lines have changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -486,7 +488,7 @@ describe('DocManager', function () { }) it('should get the existing doc', function () { - this.DocManager.promises._getDoc + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { version: true, rev: true, @@ -498,7 +500,7 @@ describe('DocManager', function () { }) it('should upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection + this.MongoManager.upsertIntoDocCollection .calledWith(this.project_id, this.doc_id, this.rev, { lines: this.newDocLines, }) @@ -512,9 +514,9 @@ describe('DocManager', function () { describe('when the doc ranges have changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) + this.DocManager._getDoc = sinon.stub().resolves(this.doc) this.RangeManager.shouldUpdateRanges.returns(true) - this.result = await this.DocManager.promises.updateDoc( + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -524,7 +526,7 @@ describe('DocManager', function () { }) it('should upsert the ranges', function () { - this.MongoManager.promises.upsertIntoDocCollection + this.MongoManager.upsertIntoDocCollection .calledWith(this.project_id, this.doc_id, this.rev, { ranges: this.newRanges, }) @@ -538,8 +540,8 @@ describe('DocManager', function () { describe('when only the version has changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -549,7 +551,7 @@ describe('DocManager', function () { }) it('should update the version', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, this.rev, @@ -564,8 +566,8 @@ describe('DocManager', function () { describe('when the doc has not changed at all', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -575,9 +577,7 @@ describe('DocManager', function () { }) it('should not update the ranges or lines or version', function () { - this.MongoManager.promises.upsertIntoDocCollection.called.should.equal( - false - ) + this.MongoManager.upsertIntoDocCollection.called.should.equal(false) }) it('should return the old rev and modified == false', function () { @@ -588,7 +588,7 @@ describe('DocManager', function () { describe('when the version is null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -602,7 +602,7 @@ describe('DocManager', function () { describe('when the lines are null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, null, @@ -616,7 +616,7 @@ describe('DocManager', function () { describe('when the ranges are null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -630,9 +630,9 @@ describe('DocManager', function () { describe('when there is a generic error getting the doc', function () { beforeEach(async function () { this.error = new Error('doc could not be found') - this.DocManager.promises._getDoc = sinon.stub().rejects(this.error) + this.DocManager._getDoc = sinon.stub().rejects(this.error) await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -643,16 +643,15 @@ describe('DocManager', function () { }) it('should not upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.not.have.been - .called + this.MongoManager.upsertIntoDocCollection.should.not.have.been.called }) }) describe('when the version was decremented', function () { it('should return an error', async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) + this.DocManager._getDoc = sinon.stub().resolves(this.doc) await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -665,8 +664,8 @@ describe('DocManager', function () { describe('when the doc lines have not changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines.slice(), @@ -676,9 +675,7 @@ describe('DocManager', function () { }) it('should not update the doc', function () { - this.MongoManager.promises.upsertIntoDocCollection.called.should.equal( - false - ) + this.MongoManager.upsertIntoDocCollection.called.should.equal(false) }) it('should return the existing rev', function () { @@ -688,8 +685,8 @@ describe('DocManager', function () { describe('when the doc does not exist', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(null) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(null) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -699,7 +696,7 @@ describe('DocManager', function () { }) it('should upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, undefined, @@ -718,12 +715,12 @@ describe('DocManager', function () { describe('when another update is racing', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.MongoManager.promises.upsertIntoDocCollection + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.MongoManager.upsertIntoDocCollection .onFirstCall() .rejects(new Errors.DocRevValueError()) this.RangeManager.shouldUpdateRanges.returns(true) - this.result = await this.DocManager.promises.updateDoc( + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -733,7 +730,7 @@ describe('DocManager', function () { }) it('should upsert the doc twice', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, this.rev, @@ -743,8 +740,7 @@ describe('DocManager', function () { version: this.version + 1, } ) - this.MongoManager.promises.upsertIntoDocCollection.should.have.been - .calledTwice + this.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice }) it('should return the new rev', function () { diff --git a/services/docstore/test/unit/js/HttpControllerTests.js b/services/docstore/test/unit/js/HttpControllerTests.js index bf78696890..ab491ec150 100644 --- a/services/docstore/test/unit/js/HttpControllerTests.js +++ b/services/docstore/test/unit/js/HttpControllerTests.js @@ -14,7 +14,7 @@ describe('HttpController', function () { max_doc_length: 2 * 1024 * 1024, } this.DocArchiveManager = { - unArchiveAllDocs: sinon.stub().yields(), + unArchiveAllDocs: sinon.stub().returns(), } this.DocManager = {} this.HttpController = SandboxedModule.require(modulePath, { @@ -54,15 +54,13 @@ describe('HttpController', function () { describe('getDoc', function () { describe('without deleted docs', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getFullDoc = sinon - .stub() - .callsArgWith(2, null, this.doc) - this.HttpController.getDoc(this.req, this.res, this.next) + this.DocManager.getFullDoc = sinon.stub().resolves(this.doc) + await this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the document with the version (including deleted)', function () { @@ -89,26 +87,24 @@ describe('HttpController', function () { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getFullDoc = sinon - .stub() - .callsArgWith(2, null, this.deletedDoc) + this.DocManager.getFullDoc = sinon.stub().resolves(this.deletedDoc) }) - it('should get the doc from the doc manager', function () { - this.HttpController.getDoc(this.req, this.res, this.next) + it('should get the doc from the doc manager', async function () { + await this.HttpController.getDoc(this.req, this.res, this.next) this.DocManager.getFullDoc .calledWith(this.projectId, this.docId) .should.equal(true) }) - it('should return 404 if the query string delete is not set ', function () { - this.HttpController.getDoc(this.req, this.res, this.next) + it('should return 404 if the query string delete is not set ', async function () { + await this.HttpController.getDoc(this.req, this.res, this.next) this.res.sendStatus.calledWith(404).should.equal(true) }) - it('should return the doc as JSON if include_deleted is set to true', function () { + it('should return the doc as JSON if include_deleted is set to true', async function () { this.req.query.include_deleted = 'true' - this.HttpController.getDoc(this.req, this.res, this.next) + await this.HttpController.getDoc(this.req, this.res, this.next) this.res.json .calledWith({ _id: this.docId, @@ -123,13 +119,15 @@ describe('HttpController', function () { }) describe('getRawDoc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getDocLines = sinon.stub().callsArgWith(2, null, this.doc) - this.HttpController.getRawDoc(this.req, this.res, this.next) + this.DocManager.getDocLines = sinon + .stub() + .resolves(this.doc.lines.join('\n')) + await this.HttpController.getRawDoc(this.req, this.res, this.next) }) it('should get the document without the version', function () { @@ -154,7 +152,7 @@ describe('HttpController', function () { describe('getAllDocs', function () { describe('normally', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -168,10 +166,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should get all the (non-deleted) docs', function () { @@ -199,7 +195,7 @@ describe('HttpController', function () { }) describe('with null lines', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -213,10 +209,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should return the doc with fallback lines', function () { @@ -238,7 +232,7 @@ describe('HttpController', function () { }) describe('with a null doc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -253,10 +247,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should return the non null docs as JSON', function () { @@ -292,7 +284,7 @@ describe('HttpController', function () { describe('getAllRanges', function () { describe('normally', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -304,10 +296,8 @@ describe('HttpController', function () { ranges: { mock_ranges: 'two' }, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllRanges(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllRanges(this.req, this.res, this.next) }) it('should get all the (non-deleted) doc ranges', function () { @@ -342,16 +332,17 @@ describe('HttpController', function () { }) describe('when the doc lines exist and were updated', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = ['hello', 'world']), version: (this.version = 42), ranges: (this.ranges = { changes: 'mock' }), } + this.rev = 5 this.DocManager.updateDoc = sinon .stub() - .yields(null, true, (this.rev = 5)) - this.HttpController.updateDoc(this.req, this.res, this.next) + .resolves({ modified: true, rev: this.rev }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should update the document', function () { @@ -374,16 +365,17 @@ describe('HttpController', function () { }) describe('when the doc lines exist and were not updated', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = ['hello', 'world']), version: (this.version = 42), ranges: {}, } + this.rev = 5 this.DocManager.updateDoc = sinon .stub() - .yields(null, false, (this.rev = 5)) - this.HttpController.updateDoc(this.req, this.res, this.next) + .resolves({ modified: false, rev: this.rev }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should return a modified status', function () { @@ -394,10 +386,12 @@ describe('HttpController', function () { }) describe('when the doc lines are not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { version: 42, ranges: {} } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -410,10 +404,12 @@ describe('HttpController', function () { }) describe('when the doc version are not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { version: 42, lines: ['hello world'] } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -426,10 +422,12 @@ describe('HttpController', function () { }) describe('when the doc ranges is not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: ['foo'], version: 42 } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -442,13 +440,20 @@ describe('HttpController', function () { }) describe('when the doc body is too large', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = Array(2049).fill('a'.repeat(1024))), version: (this.version = 42), ranges: (this.ranges = { changes: 'mock' }), } - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) + }) + + it('should not update the document', function () { + this.DocManager.updateDoc.called.should.equal(false) }) it('should return a 413 (too large) response', function () { @@ -462,14 +467,14 @@ describe('HttpController', function () { }) describe('patchDoc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } this.req.body = { name: 'foo.tex' } - this.DocManager.patchDoc = sinon.stub().yields(null) - this.HttpController.patchDoc(this.req, this.res, this.next) + this.DocManager.patchDoc = sinon.stub().resolves() + await this.HttpController.patchDoc(this.req, this.res, this.next) }) it('should delete the document', function () { @@ -484,11 +489,11 @@ describe('HttpController', function () { }) describe('with an invalid payload', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { cannot: 'happen' } - this.DocManager.patchDoc = sinon.stub().yields(null) - this.HttpController.patchDoc(this.req, this.res, this.next) + this.DocManager.patchDoc = sinon.stub().resolves() + await this.HttpController.patchDoc(this.req, this.res, this.next) }) it('should log a message', function () { @@ -509,10 +514,10 @@ describe('HttpController', function () { }) describe('archiveAllDocs', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } - this.DocArchiveManager.archiveAllDocs = sinon.stub().callsArg(1) - this.HttpController.archiveAllDocs(this.req, this.res, this.next) + this.DocArchiveManager.archiveAllDocs = sinon.stub().resolves() + await this.HttpController.archiveAllDocs(this.req, this.res, this.next) }) it('should archive the project', function () { @@ -532,9 +537,12 @@ describe('HttpController', function () { }) describe('on success', function () { - beforeEach(function (done) { - this.res.sendStatus.callsFake(() => done()) - this.HttpController.unArchiveAllDocs(this.req, this.res, this.next) + beforeEach(async function () { + await this.HttpController.unArchiveAllDocs( + this.req, + this.res, + this.next + ) }) it('returns a 200', function () { @@ -543,12 +551,15 @@ describe('HttpController', function () { }) describe("when the archived rev doesn't match", function () { - beforeEach(function (done) { - this.res.sendStatus.callsFake(() => done()) - this.DocArchiveManager.unArchiveAllDocs.yields( + beforeEach(async function () { + this.DocArchiveManager.unArchiveAllDocs.rejects( new Errors.DocRevValueError('bad rev') ) - this.HttpController.unArchiveAllDocs(this.req, this.res, this.next) + await this.HttpController.unArchiveAllDocs( + this.req, + this.res, + this.next + ) }) it('returns a 409', function () { @@ -558,10 +569,10 @@ describe('HttpController', function () { }) describe('destroyProject', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } - this.DocArchiveManager.destroyProject = sinon.stub().callsArg(1) - this.HttpController.destroyProject(this.req, this.res, this.next) + this.DocArchiveManager.destroyProject = sinon.stub().resolves() + await this.HttpController.destroyProject(this.req, this.res, this.next) }) it('should destroy the docs', function () { diff --git a/services/docstore/test/unit/js/MongoManagerTests.js b/services/docstore/test/unit/js/MongoManagerTests.js index 4f8467db76..b96b661df4 100644 --- a/services/docstore/test/unit/js/MongoManagerTests.js +++ b/services/docstore/test/unit/js/MongoManagerTests.js @@ -41,7 +41,7 @@ describe('MongoManager', function () { this.doc = { name: 'mock-doc' } this.db.docs.findOne = sinon.stub().resolves(this.doc) this.filter = { lines: true } - this.result = await this.MongoManager.promises.findDoc( + this.result = await this.MongoManager.findDoc( this.projectId, this.docId, this.filter @@ -70,11 +70,7 @@ describe('MongoManager', function () { describe('patchDoc', function () { beforeEach(async function () { this.meta = { name: 'foo.tex' } - await this.MongoManager.promises.patchDoc( - this.projectId, - this.docId, - this.meta - ) + await this.MongoManager.patchDoc(this.projectId, this.docId, this.meta) }) it('should pass the parameter along', function () { @@ -104,7 +100,7 @@ describe('MongoManager', function () { describe('with included_deleted = false', function () { beforeEach(async function () { - this.result = await this.MongoManager.promises.getProjectsDocs( + this.result = await this.MongoManager.getProjectsDocs( this.projectId, { include_deleted: false }, this.filter @@ -132,7 +128,7 @@ describe('MongoManager', function () { describe('with included_deleted = true', function () { beforeEach(async function () { - this.result = await this.MongoManager.promises.getProjectsDocs( + this.result = await this.MongoManager.getProjectsDocs( this.projectId, { include_deleted: true }, this.filter @@ -167,7 +163,7 @@ describe('MongoManager', function () { this.db.docs.find = sinon.stub().returns({ toArray: sinon.stub().resolves([this.doc1, this.doc2, this.doc3]), }) - this.result = await this.MongoManager.promises.getProjectsDeletedDocs( + this.result = await this.MongoManager.getProjectsDeletedDocs( this.projectId, this.filter ) @@ -203,7 +199,7 @@ describe('MongoManager', function () { }) it('should upsert the document', async function () { - await this.MongoManager.promises.upsertIntoDocCollection( + await this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, this.oldRev, @@ -223,7 +219,7 @@ describe('MongoManager', function () { it('should handle update error', async function () { this.db.docs.updateOne.rejects(this.stubbedErr) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, this.rev, @@ -235,7 +231,7 @@ describe('MongoManager', function () { }) it('should insert without a previous rev', async function () { - await this.MongoManager.promises.upsertIntoDocCollection( + await this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -254,7 +250,7 @@ describe('MongoManager', function () { it('should handle generic insert error', async function () { this.db.docs.insertOne.rejects(this.stubbedErr) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -266,7 +262,7 @@ describe('MongoManager', function () { it('should handle duplicate insert error', async function () { this.db.docs.insertOne.rejects({ code: 11000 }) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -280,7 +276,7 @@ describe('MongoManager', function () { beforeEach(async function () { this.projectId = new ObjectId() this.db.docs.deleteMany = sinon.stub().resolves() - await this.MongoManager.promises.destroyProject(this.projectId) + await this.MongoManager.destroyProject(this.projectId) }) it('should destroy all docs', function () { @@ -297,13 +293,13 @@ describe('MongoManager', function () { it('should not error when the rev has not changed', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 1 }) - await this.MongoManager.promises.checkRevUnchanged(this.doc) + await this.MongoManager.checkRevUnchanged(this.doc) }) it('should return an error when the rev has changed', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocModifiedError) }) @@ -311,14 +307,14 @@ describe('MongoManager', function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) this.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN } await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocRevValueError) }) it('should return a value error if checked doc rev is NaN', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: NaN }) await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocRevValueError) }) }) @@ -334,7 +330,7 @@ describe('MongoManager', function () { describe('complete doc', function () { beforeEach(async function () { - await this.MongoManager.promises.restoreArchivedDoc( + await this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc @@ -364,7 +360,7 @@ describe('MongoManager', function () { describe('without ranges', function () { beforeEach(async function () { delete this.archivedDoc.ranges - await this.MongoManager.promises.restoreArchivedDoc( + await this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc @@ -395,7 +391,7 @@ describe('MongoManager', function () { it('throws a DocRevValueError', async function () { this.db.docs.updateOne.resolves({ matchedCount: 0 }) await expect( - this.MongoManager.promises.restoreArchivedDoc( + this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc From b946c2abff72510e406facd50f4fafb44ae2fcd2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 15:42:42 +0100 Subject: [PATCH 100/209] Merge pull request #26304 from overleaf/bg-history-redis-clear-persist-time-on-persist add persist time handling to setPersistedVersion method GitOrigin-RevId: 5e115b49116ee4604e3e478c206c7e9cf147cbc8 --- .../storage/lib/chunk_store/redis.js | 11 ++++- .../storage/chunk_store_redis_backend.test.js | 44 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js index 9163536342..b43bdf8117 100644 --- a/services/history-v1/storage/lib/chunk_store/redis.js +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -480,11 +480,12 @@ async function getNonPersistedChanges(projectId, baseVersion) { } rclient.defineCommand('set_persisted_version', { - numberOfKeys: 3, + numberOfKeys: 4, lua: ` local headVersionKey = KEYS[1] local persistedVersionKey = KEYS[2] - local changesKey = KEYS[3] + local persistTimeKey = KEYS[3] + local changesKey = KEYS[4] local newPersistedVersion = tonumber(ARGV[1]) local maxPersistedChanges = tonumber(ARGV[2]) @@ -509,6 +510,11 @@ rclient.defineCommand('set_persisted_version', { -- Set the persisted version redis.call('SET', persistedVersionKey, newPersistedVersion) + -- Clear the persist time if the persisted version now matches the head version + if newPersistedVersion == headVersion then + redis.call('DEL', persistTimeKey) + end + -- Calculate the starting index, to keep only maxPersistedChanges beyond the persisted version -- Using negative indexing to count backwards from the end of the list local startIndex = newPersistedVersion - headVersion - maxPersistedChanges @@ -535,6 +541,7 @@ async function setPersistedVersion(projectId, persistedVersion) { const keys = [ keySchema.headVersion({ projectId }), keySchema.persistedVersion({ projectId }), + keySchema.persistTime({ projectId }), keySchema.changes({ projectId }), ] diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js index 04d801c73d..d34cd701d0 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -699,6 +699,8 @@ describe('chunk buffer Redis backend', function () { }) describe('setPersistedVersion', function () { + const persistTime = Date.now() + 60 * 1000 // 1 minute from now + it('should return not_found when project does not exist', async function () { const result = await redisBackend.setPersistedVersion(projectId, 5) expect(result).to.equal('not_found') @@ -709,6 +711,7 @@ describe('chunk buffer Redis backend', function () { await setupState(projectId, { headVersion: 5, persistedVersion: null, + persistTime, changes: 5, }) }) @@ -720,6 +723,13 @@ describe('chunk buffer Redis backend', function () { expect(state.persistedVersion).to.equal(3) }) + it('should leave the persist time if the persisted version is not current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 3) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistTime).to.deep.equal(persistTime) // Persist time should remain unchanged + }) + it('should refuse to set a persisted version greater than the head version', async function () { await expect( redisBackend.setPersistedVersion(projectId, 10) @@ -728,6 +738,14 @@ describe('chunk buffer Redis backend', function () { const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.be.null }) + + it('should clear the persist time when the persisted version is current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(5) + expect(state.persistTime).to.be.null // Persist time should be cleared + }) }) describe('when the persisted version is set', function () { @@ -735,6 +753,7 @@ describe('chunk buffer Redis backend', function () { await setupState(projectId, { headVersion: 5, persistedVersion: 3, + persistTime, changes: 5, }) }) @@ -746,6 +765,22 @@ describe('chunk buffer Redis backend', function () { expect(state.persistedVersion).to.equal(5) }) + it('should clear the persist time when the persisted version is current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(5) + expect(state.persistTime).to.be.null // Persist time should be cleared + }) + + it('should leave the persist time if the persisted version is not current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 4) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(4) + expect(state.persistTime).to.deep.equal(persistTime) // Persist time should remain unchanged + }) + it('should not decrease the persisted version', async function () { const status = await redisBackend.setPersistedVersion(projectId, 2) expect(status).to.equal('too_low') @@ -1183,6 +1218,8 @@ function makeChange() { * @param {object} params * @param {number} params.headVersion * @param {number | null} params.persistedVersion + * @param {number | null} params.persistTime - time when the project should be persisted + * @param {number | null} params.expireTime - time when the project should expire * @param {number} params.changes - number of changes to create * @return {Promise} dummy changes that have been created */ @@ -1194,7 +1231,12 @@ async function setupState(projectId, params) { params.persistedVersion ) } - + if (params.persistTime) { + await rclient.set(keySchema.persistTime({ projectId }), params.persistTime) + } + if (params.expireTime) { + await rclient.set(keySchema.expireTime({ projectId }), params.expireTime) + } const changes = [] for (let i = 1; i <= params.changes; i++) { const change = new Change( From 07b47606c10c3d97a96324deb9f7aa9e6b282982 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 15:41:19 +0100 Subject: [PATCH 101/209] Disable script in production GitOrigin-RevId: 81fe077a5816a23fa20c78a6271fbdf62021e3b2 --- services/web/scripts/recurly/resync_subscriptions.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/web/scripts/recurly/resync_subscriptions.mjs b/services/web/scripts/recurly/resync_subscriptions.mjs index a0b5ca1438..4965835bf4 100644 --- a/services/web/scripts/recurly/resync_subscriptions.mjs +++ b/services/web/scripts/recurly/resync_subscriptions.mjs @@ -181,6 +181,13 @@ const setup = () => { } } +if (process.env.NODE_ENV !== 'development') { + console.warn( + 'This script can cause issues with manually amended subscriptions and can also exhaust our rate-limit with Recurly so is not intended to be run in production. Please use it in development environments only.' + ) + process.exit(1) +} + setup() await run() process.exit() From 5799d534a99cc57fa6fcd2594e9486065d1c4f0d Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 15:42:01 +0100 Subject: [PATCH 102/209] Ensure we wait after processing each subscription GitOrigin-RevId: f6a184bc8a65934f24857cfc4f71f95574576b9d --- .../scripts/recurly/resync_subscriptions.mjs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/services/web/scripts/recurly/resync_subscriptions.mjs b/services/web/scripts/recurly/resync_subscriptions.mjs index 4965835bf4..6a03c8e3c1 100644 --- a/services/web/scripts/recurly/resync_subscriptions.mjs +++ b/services/web/scripts/recurly/resync_subscriptions.mjs @@ -89,20 +89,18 @@ const syncSubscription = async subscription => { ScriptLogger.recordMismatch(subscription, recurlySubscription) - if (!COMMIT) { - return + if (COMMIT) { + try { + await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + recurlySubscription, + subscription, + {} + ) + } catch (error) { + await handleSyncSubscriptionError(subscription, error) + } } - try { - await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - recurlySubscription, - subscription, - {} - ) - } catch (error) { - await handleSyncSubscriptionError(subscription, error) - return - } await setTimeout(80) } From b290e93441247fab8aa2785b306a3c33d6730ce7 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 17:45:02 +0100 Subject: [PATCH 103/209] Merge pull request #26270 from overleaf/bg-history-redis-commit-change-manager replace redis logic in persistChanges with new commitChanges method GitOrigin-RevId: e06f9477b9d5548fa92ef87fb6e1f4f672001a35 --- .../api/controllers/project_import.js | 4 +- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/commit_changes.js | 93 +++++++++++++++++++ .../history-v1/storage/lib/persist_changes.js | 67 +------------ 4 files changed, 97 insertions(+), 68 deletions(-) create mode 100644 services/history-v1/storage/lib/commit_changes.js diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index dee45efce8..e71eee93f9 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -21,7 +21,7 @@ const BatchBlobStore = storage.BatchBlobStore const BlobStore = storage.BlobStore const chunkStore = storage.chunkStore const HashCheckBlobStore = storage.HashCheckBlobStore -const persistChanges = storage.persistChanges +const commitChanges = storage.commitChanges const InvalidChangeError = storage.InvalidChangeError const render = require('./render') @@ -110,7 +110,7 @@ async function importChanges(req, res, next) { let result try { - result = await persistChanges(projectId, changes, limits, endVersion, { + result = await commitChanges(projectId, changes, limits, endVersion, { queueChangesInRedis: true, }) } catch (err) { diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index a07c98c026..82a51583be 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -9,6 +9,7 @@ exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') exports.persistBuffer = require('./lib/persist_buffer') +exports.commitChanges = require('./lib/commit_changes') exports.queueChanges = require('./lib/queue_changes') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') diff --git a/services/history-v1/storage/lib/commit_changes.js b/services/history-v1/storage/lib/commit_changes.js new file mode 100644 index 0000000000..fa22e05bbf --- /dev/null +++ b/services/history-v1/storage/lib/commit_changes.js @@ -0,0 +1,93 @@ +// @ts-check + +'use strict' + +const metrics = require('@overleaf/metrics') +const redisBackend = require('./chunk_store/redis') +const logger = require('@overleaf/logger') +const queueChanges = require('./queue_changes') +const persistChanges = require('./persist_changes') + +/** + * @typedef {import('overleaf-editor-core').Change} Change + */ + +/** + * Handle incoming changes by processing them according to the specified options. + * @param {string} projectId + * @param {Change[]} changes + * @param {Object} limits + * @param {number} endVersion + * @param {Object} options + * @param {Boolean} [options.queueChangesInRedis] + * If true, queue the changes in Redis for testing purposes. + * @return {Promise.} + */ + +async function commitChanges( + projectId, + changes, + limits, + endVersion, + options = {} +) { + if (options.queueChangesInRedis) { + try { + await queueChanges(projectId, changes, endVersion) + await fakePersistRedisChanges(projectId, changes, endVersion) + } catch (err) { + logger.error({ err }, 'Chunk buffer verification failed') + } + } + const result = await persistChanges(projectId, changes, limits, endVersion) + return result +} + +/** + * Simulates the persistence of changes by verifying a given set of changes against + * what is currently stored as non-persisted in Redis, and then updates the + * persisted version number in Redis. + * + * @async + * @param {string} projectId - The ID of the project. + * @param {Change[]} changesToPersist - An array of changes that are expected to be + * persisted. These are used for verification + * against the changes currently in Redis. + * @param {number} baseVersion - The base version number from which to calculate + * the new persisted version. + * @returns {Promise} A promise that resolves when the persisted version + * in Redis has been updated. + */ +async function fakePersistRedisChanges( + projectId, + changesToPersist, + baseVersion +) { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + baseVersion + ) + + if ( + serializeChanges(nonPersistedChanges) === serializeChanges(changesToPersist) + ) { + metrics.inc('persist_redis_changes_verification', 1, { status: 'match' }) + } else { + logger.warn({ projectId }, 'mismatch of non-persisted changes from Redis') + metrics.inc('persist_redis_changes_verification', 1, { + status: 'mismatch', + }) + } + + const persistedVersion = baseVersion + nonPersistedChanges.length + await redisBackend.setPersistedVersion(projectId, persistedVersion) +} + +/** + * @param {Change[]} changes + */ +function serializeChanges(changes) { + return JSON.stringify(changes.map(change => change.toRaw())) +} + +module.exports = commitChanges diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js index 95ffdc67d2..d2ca00053f 100644 --- a/services/history-v1/storage/lib/persist_changes.js +++ b/services/history-v1/storage/lib/persist_changes.js @@ -4,7 +4,6 @@ const _ = require('lodash') const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') const core = require('overleaf-editor-core') const Chunk = core.Chunk @@ -15,7 +14,6 @@ const chunkStore = require('./chunk_store') const { BlobStore } = require('./blob_store') const { InvalidChangeError } = require('./errors') const { getContentHash } = require('./content_hash') -const redisBackend = require('./chunk_store/redis') function countChangeBytes(change) { // Note: This is not quite accurate, because the raw change may contain raw @@ -57,18 +55,9 @@ Timer.prototype.elapsed = function () { * @param {core.Change[]} allChanges * @param {Object} limits * @param {number} clientEndVersion - * @param {Object} options - * @param {Boolean} [options.queueChangesInRedis] - * If true, queue the changes in Redis for testing purposes. * @return {Promise.} */ -async function persistChanges( - projectId, - allChanges, - limits, - clientEndVersion, - options = {} -) { +async function persistChanges(projectId, allChanges, limits, clientEndVersion) { assert.projectId(projectId) assert.array(allChanges) assert.maybe.object(limits) @@ -211,45 +200,6 @@ async function persistChanges( currentSnapshot.applyAll(currentChunk.getChanges()) } - async function queueChangesInRedis() { - const hollowSnapshot = currentSnapshot.clone() - // We're transforming a lazy snapshot to a hollow snapshot, so loadFiles() - // doesn't really need a blobStore, but its signature still requires it. - const blobStore = new BlobStore(projectId) - await hollowSnapshot.loadFiles('hollow', blobStore) - hollowSnapshot.applyAll(changesToPersist, { strict: true }) - const baseVersion = currentChunk.getEndVersion() - await redisBackend.queueChanges( - projectId, - hollowSnapshot, - baseVersion, - changesToPersist - ) - } - - async function fakePersistRedisChanges() { - const baseVersion = currentChunk.getEndVersion() - const nonPersistedChanges = await redisBackend.getNonPersistedChanges( - projectId, - baseVersion - ) - - if ( - serializeChanges(nonPersistedChanges) === - serializeChanges(changesToPersist) - ) { - metrics.inc('persist_redis_changes_verification', 1, { status: 'match' }) - } else { - logger.warn({ projectId }, 'mismatch of non-persisted changes from Redis') - metrics.inc('persist_redis_changes_verification', 1, { - status: 'mismatch', - }) - } - - const persistedVersion = baseVersion + nonPersistedChanges.length - await redisBackend.setPersistedVersion(projectId, persistedVersion) - } - async function extendLastChunkIfPossible() { const timer = new Timer() const changesPushed = await fillChunk(currentChunk, changesToPersist) @@ -298,14 +248,6 @@ async function persistChanges( const numberOfChangesToPersist = oldChanges.length await loadLatestChunk() - if (options.queueChangesInRedis) { - try { - await queueChangesInRedis() - await fakePersistRedisChanges() - } catch (err) { - logger.error({ err }, 'Chunk buffer verification failed') - } - } await extendLastChunkIfPossible() await createNewChunksAsNeeded() @@ -320,11 +262,4 @@ async function persistChanges( } } -/** - * @param {core.Change[]} changes - */ -function serializeChanges(changes) { - return JSON.stringify(changes.map(change => change.toRaw())) -} - module.exports = persistChanges From 6a951e2ff0c8e6eaa85f173f55dfed9b961126ad Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Wed, 11 Jun 2025 10:28:53 +0200 Subject: [PATCH 104/209] [web] Migrate general Pug pages to BS5 (2) (#26121) * Reapply "[web] Migrate general Pug pages to BS5 (#25937)" This reverts commit c0afd7db2dde6a051043ab3e85a969c1eeb7d6a3. * Fixup layouts in `404` and `closed` pages Oversight from https://github.com/overleaf/internal/pull/25937 * Use `.container-custom-sm` and `.container-custom-md` instead of inconsistent page widths * Copy error-pages.less * Convert error-pages.lss to SCSS * Revert changes to pug files * Nest CSS in `.error-container` so nothing leaks to other pages * Remove `font-family-serif` * Use CSS variables * Remove `padding: 0` from `.full-height`: it breaks the layout in BS5 * Fix error-actions buttons * Revert changes to .container-custom... * Update services/web/app/views/external/planned_maintenance.pug Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> * Update services/web/app/views/general/closed.pug Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> --------- Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> GitOrigin-RevId: 313f04782a72fae7cc66d36f9d6467bad135fd60 --- services/web/app/views/general/400.pug | 2 - services/web/app/views/general/404.pug | 3 - services/web/app/views/general/500.pug | 1 - services/web/app/views/general/closed.pug | 5 +- .../app/views/general/unsupported-browser.pug | 1 - .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/base/layout.scss | 4 ++ .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/error-pages.scss | 56 +++++++++++++++++++ 9 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 9fc97823c5..fcc3007e6f 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body body.full-height @@ -27,5 +26,4 @@ block body | . p.error-actions a.error-btn(href="javascript:history.back()") Back - |   a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index f4b5800cf2..f76eac6997 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,8 +1,5 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 90cb1e3606..41e7440e0d 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body body.full-height diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index f4012997bd..b3f8ea2c04 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,10 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content#main-content .container .row - .col-md-8.col-md-offset-2.text-center + .col-lg-8.offset-lg-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index f8806cf8d2..a2c2216315 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } - - bootstrap5PageStatus = 'disabled' block body body.full-height diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index c86721a810..b5bf3cc434 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath()) + link(rel="stylesheet", href=buildCssPath('', 5)) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index 0733a04304..4a8d517ba6 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -91,3 +91,7 @@ hr { .container-custom-sm { max-width: 400px; } + +.full-height { + height: 100%; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index a3adc98819..f10f00842d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,6 +37,7 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; +@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss new file mode 100644 index 0000000000..ac21364f9d --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss @@ -0,0 +1,56 @@ +.error-container { + display: flex; + align-items: center; + + &.full-height { + margin-top: calc(-1 * ($header-height + var(--spacing-08)) / 2); + } + + .error-details { + flex: 1 1 50%; + padding: var(--spacing-08); + max-width: 90%; + + @include media-breakpoint-up(sm) { + padding: var(--spacing-11); + } + } + + .error-status { + @include heading-lg; + + margin-bottom: var(--spacing-08); + color: var(--content-secondary); + } + + .error-description { + @include heading-sm; + + color: var(--content-disabled-dark); + margin-bottom: var(--spacing-08); + } + + .error-box { + background-color: var(--bg-light-tertiary); + padding: var(--spacing-04); + font-family: $font-family-monospace; + margin-bottom: var(--spacing-08); + } + + .error-actions { + margin-top: var(--spacing-11); + display: flex; + gap: var(--spacing-06); + } + + .error-btn { + @extend .btn; + @extend .btn-primary; + + display: block; + + @include media-breakpoint-up(sm) { + display: inline-block; + } + } +} From b3dc0097fd60da4aff16d908c614cccd13c8a1d6 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Wed, 11 Jun 2025 10:29:30 +0200 Subject: [PATCH 105/209] Merge pull request #26188 from overleaf/ac-bs5-fix-redundant-carets [web] Fix redundant carets in BS5 marketing pages GitOrigin-RevId: 479687d982db23e4f5f2efcc3f5f39bb78f0eb24 --- .../views/layout/navbar-marketing-bootstrap-5.pug | 3 --- .../components/emails/add-email/country-input.tsx | 2 +- .../stylesheets/bootstrap-5/components/all.scss | 1 + .../stylesheets/bootstrap-5/components/caret.scss | 15 +++++++++++++++ .../stylesheets/bootstrap-5/components/form.scss | 5 +++++ 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/components/caret.scss diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index 92e2d4301d..c581ab29ce 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -55,7 +55,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": "admin", "location": "top-menu"} ) | Admin - span.caret +dropdown-menu.dropdown-menu-end if canDisplayAdminMenu +dropdown-menu-link-item()(href="/admin") Manage Site @@ -97,7 +96,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": item.trackingKey, "location": "top-menu"} ) | !{translate(item.text)} - span.caret +dropdown-menu.dropdown-menu-end each child in item.dropdown if child.divider @@ -173,7 +171,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": "account", "location": "top-menu"} ) | #{translate('Account')} - span.caret +dropdown-menu.dropdown-menu-end +dropdown-menu-item div.disabled.dropdown-item #{getSessionUser().email} diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx index f55344a2f2..e94b39c935 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx @@ -60,9 +60,9 @@ function Downshift({ setValue, inputRef }: CountryInputProps) { }, ref: inputRef, })} + append={} placeholder={t('country')} /> -