1 import { c } from 'ttag'
2 import type { SquashDocument } from '../UseCase/SquashDocument'
3 import type { DuplicateDocument } from '../UseCase/DuplicateDocument'
4 import type { CreateNewDocument } from '../UseCase/CreateNewDocument'
5 import type { DriveCompat } from '@proton/drive-store'
6 import type { InternalEventBusInterface, YjsState } from '@proton/docs-shared'
7 import type { AuthenticatedDocControllerInterface } from './AuthenticatedDocControllerInterface'
8 import type { SeedInitialCommit } from '../UseCase/SeedInitialCommit'
9 import type { VersionHistoryUpdate } from '../VersionHistory'
10 import { NativeVersionHistory } from '../VersionHistory'
11 import { DocControllerEvent } from './AuthenticatedDocControllerEvent'
13 import type { DocsClientSquashVerificationObjectionMadePayload } from '../Application/ApplicationEvent'
14 import { ApplicationEvent, PostApplicationError } from '../Application/ApplicationEvent'
15 import type { SquashVerificationObjectionCallback } from '../Types/SquashVerificationObjection'
16 import { TranslatedResult } from '@proton/docs-shared'
17 import type { Result } from '@proton/docs-shared'
18 import { getPlatformFriendlyDateForFileName } from '../Util/PlatformFriendlyFileNameDate'
19 import { MAX_DOC_SIZE } from '../Models/Constants'
20 import type { GetNode } from '../UseCase/GetNode'
21 import { isDocumentState, type DocumentState } from '../State/DocumentState'
22 import type { LoggerInterface } from '@proton/utils/logs'
23 import { getErrorString } from '../Util/GetErrorString'
26 * Controls the lifecycle of a single document for an authenticated user.
28 export class AuthenticatedDocController implements AuthenticatedDocControllerInterface {
30 didTrashDocInCurrentSession = false
31 /** Used for history tracking in Version History */
32 receivedOrSentDUs: VersionHistoryUpdate[] = []
35 private readonly documentState: DocumentState,
36 private driveCompat: DriveCompat,
37 private _squashDocument: SquashDocument,
38 readonly _createInitialCommit: SeedInitialCommit,
39 private _duplicateDocument: DuplicateDocument,
40 private _createNewDocument: CreateNewDocument,
41 readonly _getNode: GetNode,
42 readonly eventBus: InternalEventBusInterface,
43 readonly logger: LoggerInterface,
45 this.subscribeToEvents()
49 this.isDestroyed = true
52 subscribeToEvents(): void {
53 this.documentState.subscribeToProperty('baseCommit', (value) => {
54 if (value && value.needsSquash()) {
55 void this.squashDocument()
59 this.documentState.subscribeToEvent('EditorRequestsPropagationOfUpdate', (payload) => {
60 if (this.isDestroyed) {
64 if (payload.message.type.wrapper === 'conversion') {
65 void this.handleEditorProvidingInitialConversionContent(payload.message.content)
66 } else if (payload.message.type.wrapper === 'du') {
67 this.receivedOrSentDUs.push({
68 content: payload.message.content,
69 timestamp: Date.now(),
74 this.documentState.subscribeToProperty('baseCommit', (value) => {
76 this.receivedOrSentDUs = []
80 this.documentState.subscribeToEvent('RealtimeReceivedDocumentUpdate', (payload) => {
81 this.receivedOrSentDUs.push(payload)
85 public getVersionHistory(): NativeVersionHistory | undefined {
86 const updates = [...(this.documentState.getProperty('baseCommit')?.updates ?? []), ...this.receivedOrSentDUs]
88 return updates.length > 0 ? new NativeVersionHistory(updates) : undefined
93 * @param imposeTrashState getNode may return a cached value, due to a race condition with DriveCompat where the node
94 * is removed from cache but done asyncronously. So the refetch below might return a cached value when we are expecting
95 * a fresh reloaded value. This should ultimately be fixed with the DriveCompat but goes too deep into its functionality
98 async refreshNodeAndDocMeta(options: { imposeTrashState: 'trashed' | 'not_trashed' | undefined }): Promise<void> {
99 const docMeta = this.documentState.getProperty('documentMeta')
100 const { nodeMeta } = this.documentState.getProperty('entitlements')
102 const result = await this._getNode.execute(nodeMeta, docMeta)
103 if (result.isFailed()) {
104 this.logger.error('Failed to get node', result.getError())
108 const { node, refreshedDocMeta } = result.getValue()
109 this.documentState.setProperty('decryptedNode', node)
111 if (refreshedDocMeta) {
112 this.documentState.setProperty('documentMeta', refreshedDocMeta)
113 this.documentState.setProperty('documentName', refreshedDocMeta.name)
116 if (options.imposeTrashState) {
117 this.documentState.setProperty('documentTrashState', options.imposeTrashState)
119 this.documentState.setProperty('documentTrashState', node.trashed ? 'trashed' : 'not_trashed')
123 async handleEditorProvidingInitialConversionContent(content: Uint8Array): Promise<void> {
124 this.logger.info('Received conversion content from editor, seeding initial commit of size', content.byteLength)
126 this.documentState.emitEvent({
127 name: 'DriveFileConversionToDocBegan',
131 if (content.byteLength >= MAX_DOC_SIZE) {
132 this.logger.info('Initial conversion content is too large')
134 PostApplicationError(this.eventBus, {
135 translatedError: c('Error')
136 .t`The document you are trying to convert is too large. This may occur if the document has a large number of images or other media. Please try again with a smaller document.`,
143 const result = await this.createInitialCommit(content)
145 if (result.isFailed()) {
146 PostApplicationError(this.eventBus, {
147 translatedError: c('Error').t`An error occurred while attempting to convert the document. Please try again.`,
154 this.documentState.emitEvent({
155 name: 'DriveFileConversionToDocSucceeded',
160 public async debugSendCommitCommandToRTS(): Promise<void> {
161 if (!isDocumentState(this.documentState)) {
165 this.documentState.emitEvent({
166 name: 'DebugMenuRequestingCommitWithRTS',
167 payload: this.documentState.getProperty('entitlements'),
171 public async createInitialCommit(content: Uint8Array): Promise<Result<unknown>> {
172 if (!isDocumentState(this.documentState)) {
173 throw new Error('Cannot perform createInitialCommit as a public user')
176 const result = await this._createInitialCommit.execute(
177 this.documentState.getProperty('entitlements').nodeMeta,
179 this.documentState.getProperty('entitlements').keys,
182 if (result.isFailed()) {
183 this.logger.error('Failed to seed document', result.getError())
185 const resultValue = result.getValue()
186 this.documentState.setProperty('currentCommitId', resultValue.commitId)
192 public async squashDocument(): Promise<void> {
193 if (!isDocumentState(this.documentState)) {
194 throw new Error('Cannot perform squashDocument as a public user')
197 const baseCommit = this.documentState.getProperty('baseCommit')
199 this.logger.info('No initial commit to squash')
203 this.logger.info('Squashing document')
205 const handleVerificationObjection: SquashVerificationObjectionCallback = async () => {
206 this.eventBus.publish({
207 type: DocControllerEvent.SquashVerificationObjectionDecisionRequired,
211 return new Promise((resolve) => {
212 const disposer = this.eventBus.addEventCallback((data: DocsClientSquashVerificationObjectionMadePayload) => {
214 resolve(data.decision)
215 }, ApplicationEvent.SquashVerificationObjectionDecisionMade)
219 const { keys, nodeMeta } = this.documentState.getProperty('entitlements')
221 const result = await this._squashDocument.execute({
223 commitId: baseCommit.commitId,
225 handleVerificationObjection,
228 if (result.isFailed()) {
229 this.logger.error('Failed to squash document', result.getError())
231 this.logger.info('Squash result', result.getValue())
235 public async duplicateDocument(editorYjsState: Uint8Array): Promise<void> {
236 const result = await this._duplicateDocument.executePrivate(
237 this.documentState.getProperty('entitlements').nodeMeta,
238 this.documentState.getProperty('documentMeta'),
242 if (result.isFailed()) {
243 this.logger.error('Failed to duplicate document', result.getError())
245 PostApplicationError(this.eventBus, {
246 translatedError: c('Error').t`An error occurred while attempting to duplicate the document. Please try again.`,
252 const shell = result.getValue()
254 void this.driveCompat.openDocument(shell)
257 public async restoreRevisionAsCopy(yjsContent: YjsState): Promise<void> {
258 const result = await this._duplicateDocument.executePrivate(
259 this.documentState.getProperty('entitlements').nodeMeta,
260 this.documentState.getProperty('documentMeta'),
264 if (result.isFailed()) {
265 this.logger.error('Failed to restore document as copy', result.getError())
267 PostApplicationError(this.eventBus, {
268 translatedError: c('Error').t`An error occurred while attempting to restore the document. Please try again.`,
274 const shell = result.getValue()
276 void this.driveCompat.openDocument(shell)
279 public async createNewDocument(): Promise<void> {
280 const date = getPlatformFriendlyDateForFileName()
281 // translator: Default title for a new Proton Document (example: Untitled document 2024-04-23)
282 const baseTitle = c('Title').t`Untitled document ${date}`
283 const newName = `${baseTitle}`
285 const result = await this._createNewDocument.execute(
287 this.documentState.getProperty('entitlements').nodeMeta,
288 this.documentState.getProperty('decryptedNode'),
291 if (result.isFailed()) {
292 this.logger.error('Failed to create new document', result.getError())
294 PostApplicationError(this.eventBus, {
295 translatedError: c('Error').t`An error occurred while creating a new document. Please try again.`,
301 const shell = result.getValue()
303 void this.driveCompat.openDocument(shell)
306 public async renameDocument(newName: string): Promise<TranslatedResult<void>> {
308 const decryptedNode = this.documentState.getProperty('decryptedNode')
309 if (!decryptedNode.parentNodeId) {
310 throw new Error('Cannot rename document')
313 const name = await this.driveCompat.findAvailableNodeName(
315 volumeId: decryptedNode.volumeId,
316 linkId: decryptedNode.parentNodeId,
320 await this.driveCompat.renameDocument(this.documentState.getProperty('entitlements').nodeMeta, name)
321 await this.refreshNodeAndDocMeta({ imposeTrashState: undefined })
322 return TranslatedResult.ok()
324 this.logger.error(getErrorString(e) ?? 'Failed to rename document')
326 return TranslatedResult.failWithTranslatedError(c('Error').t`Failed to rename document. Please try again later.`)
330 public async trashDocument(): Promise<void> {
331 this.documentState.setProperty('documentTrashState', 'trashing')
334 const decryptedNode = this.documentState.getProperty('decryptedNode')
335 const parentLinkId = decryptedNode.parentNodeId || (await this.driveCompat.getMyFilesNodeMeta()).linkId
336 await this.driveCompat.trashDocument(this.documentState.getProperty('entitlements').nodeMeta, parentLinkId)
338 await this.refreshNodeAndDocMeta({ imposeTrashState: 'trashed' })
340 this.didTrashDocInCurrentSession = true
342 this.logger.error(getErrorString(error) ?? 'Failed to trash document')
344 PostApplicationError(this.eventBus, {
345 translatedError: c('Error').t`An error occurred while attempting to trash the document. Please try again.`,
347 this.documentState.setProperty('documentTrashState', 'not_trashed')
351 public async restoreDocument(): Promise<void> {
352 this.documentState.setProperty('documentTrashState', 'restoring')
355 const decryptedNode = this.documentState.getProperty('decryptedNode')
356 const parentLinkId = decryptedNode.parentNodeId || (await this.driveCompat.getMyFilesNodeMeta()).linkId
357 await this.driveCompat.restoreDocument(this.documentState.getProperty('entitlements').nodeMeta, parentLinkId)
359 await this.refreshNodeAndDocMeta({ imposeTrashState: 'not_trashed' })
361 this.logger.error(getErrorString(error) ?? 'Failed to restore document')
363 PostApplicationError(this.eventBus, {
364 translatedError: c('Error').t`An error occurred while attempting to restore the document. Please try again.`,
366 this.documentState.setProperty('documentTrashState', 'trashed')
370 public openDocumentSharingModal(): void {
371 void this.driveCompat.openDocumentSharingModal(this.documentState.getProperty('entitlements').nodeMeta)