feat(snjs): add revisions api v2 (#2154)
* feat(snjs): add revisions api v2 * fix(snjs): reference listing and getting revisions in specs * fix(snjs): revisions specs * fix(web): usage of revision metadata * fix(snjs): add specs for decryption revision * fix(snjs): issue with building mocked specs * fix(snjs): adjust revision creation delay
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
export enum RevisionApiOperations {
|
||||||
|
List,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ErrorMessage } from '../../Error/ErrorMessage'
|
||||||
|
import { ApiCallError } from '../../Error/ApiCallError'
|
||||||
|
|
||||||
|
import { RevisionApiServiceInterface } from './RevisionApiServiceInterface'
|
||||||
|
import { RevisionApiOperations } from './RevisionApiOperations'
|
||||||
|
import { RevisionServerInterface } from '../../Server'
|
||||||
|
import { DeleteRevisionResponse } from '../../Response/Revision/DeleteRevisionResponse'
|
||||||
|
import { GetRevisionResponse } from '../../Response/Revision/GetRevisionResponse'
|
||||||
|
import { ListRevisionsResponse } from '../../Response/Revision/ListRevisionsResponse'
|
||||||
|
|
||||||
|
export class RevisionApiService implements RevisionApiServiceInterface {
|
||||||
|
private operationsInProgress: Map<RevisionApiOperations, boolean>
|
||||||
|
|
||||||
|
constructor(private revisionServer: RevisionServerInterface) {
|
||||||
|
this.operationsInProgress = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRevisions(itemUuid: string): Promise<ListRevisionsResponse> {
|
||||||
|
if (this.operationsInProgress.get(RevisionApiOperations.List)) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.List, true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.revisionServer.listRevisions({
|
||||||
|
itemUuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||||
|
} finally {
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.List, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevision(itemUuid: string, revisionUuid: string): Promise<GetRevisionResponse> {
|
||||||
|
if (this.operationsInProgress.get(RevisionApiOperations.Get)) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.Get, true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.revisionServer.getRevision({
|
||||||
|
itemUuid,
|
||||||
|
revisionUuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||||
|
} finally {
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.Get, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRevision(itemUuid: string, revisionUuid: string): Promise<DeleteRevisionResponse> {
|
||||||
|
if (this.operationsInProgress.get(RevisionApiOperations.Delete)) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.Delete, true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.revisionServer.deleteRevision({
|
||||||
|
itemUuid,
|
||||||
|
revisionUuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||||
|
} finally {
|
||||||
|
this.operationsInProgress.set(RevisionApiOperations.Delete, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { DeleteRevisionResponse } from '../../Response/Revision/DeleteRevisionResponse'
|
||||||
|
import { GetRevisionResponse } from '../../Response/Revision/GetRevisionResponse'
|
||||||
|
import { ListRevisionsResponse } from '../../Response/Revision/ListRevisionsResponse'
|
||||||
|
|
||||||
|
export interface RevisionApiServiceInterface {
|
||||||
|
listRevisions(itemUuid: string): Promise<ListRevisionsResponse>
|
||||||
|
getRevision(itemUuid: string, revisionUuid: string): Promise<GetRevisionResponse>
|
||||||
|
deleteRevision(itemUuid: string, revisionUuid: string): Promise<DeleteRevisionResponse>
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ export * from './Auth/AuthApiServiceInterface'
|
|||||||
export * from './Authenticator/AuthenticatorApiOperations'
|
export * from './Authenticator/AuthenticatorApiOperations'
|
||||||
export * from './Authenticator/AuthenticatorApiService'
|
export * from './Authenticator/AuthenticatorApiService'
|
||||||
export * from './Authenticator/AuthenticatorApiServiceInterface'
|
export * from './Authenticator/AuthenticatorApiServiceInterface'
|
||||||
|
export * from './Revision/RevisionApiOperations'
|
||||||
|
export * from './Revision/RevisionApiService'
|
||||||
|
export * from './Revision/RevisionApiServiceInterface'
|
||||||
export * from './Subscription/SubscriptionApiOperations'
|
export * from './Subscription/SubscriptionApiOperations'
|
||||||
export * from './Subscription/SubscriptionApiService'
|
export * from './Subscription/SubscriptionApiService'
|
||||||
export * from './Subscription/SubscriptionApiServiceInterface'
|
export * from './Subscription/SubscriptionApiServiceInterface'
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface DeleteRevisionRequestParams {
|
||||||
|
itemUuid: string
|
||||||
|
revisionUuid: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface GetRevisionRequestParams {
|
||||||
|
itemUuid: string
|
||||||
|
revisionUuid: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface ListRevisionsRequestParams {
|
||||||
|
itemUuid: string
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestP
|
|||||||
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
|
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
|
||||||
export * from './Recovery/RecoveryKeyParamsRequestParams'
|
export * from './Recovery/RecoveryKeyParamsRequestParams'
|
||||||
export * from './Recovery/SignInWithRecoveryCodesRequestParams'
|
export * from './Recovery/SignInWithRecoveryCodesRequestParams'
|
||||||
|
export * from './Revision/DeleteRevisionRequestParams'
|
||||||
|
export * from './Revision/GetRevisionRequestParams'
|
||||||
|
export * from './Revision/ListRevisionsRequestParams'
|
||||||
export * from './Subscription/AppleIAPConfirmRequestParams'
|
export * from './Subscription/AppleIAPConfirmRequestParams'
|
||||||
export * from './Subscription/SubscriptionInviteAcceptRequestParams'
|
export * from './Subscription/SubscriptionInviteAcceptRequestParams'
|
||||||
export * from './Subscription/SubscriptionInviteCancelRequestParams'
|
export * from './Subscription/SubscriptionInviteCancelRequestParams'
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Either } from '@standardnotes/common'
|
||||||
|
|
||||||
|
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||||
|
import { HttpResponse } from '../../Http/HttpResponse'
|
||||||
|
|
||||||
|
import { DeleteRevisionResponseBody } from './DeleteRevisionResponseBody'
|
||||||
|
|
||||||
|
export interface DeleteRevisionResponse extends HttpResponse {
|
||||||
|
data: Either<DeleteRevisionResponseBody, HttpErrorResponseBody>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface DeleteRevisionResponseBody {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Either } from '@standardnotes/common'
|
||||||
|
|
||||||
|
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||||
|
import { HttpResponse } from '../../Http/HttpResponse'
|
||||||
|
|
||||||
|
import { GetRevisionResponseBody } from './GetRevisionResponseBody'
|
||||||
|
|
||||||
|
export interface GetRevisionResponse extends HttpResponse {
|
||||||
|
data: Either<GetRevisionResponseBody, HttpErrorResponseBody>
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface GetRevisionResponseBody {
|
||||||
|
revision: {
|
||||||
|
uuid: string
|
||||||
|
item_uuid: string
|
||||||
|
content: string | null
|
||||||
|
content_type: string
|
||||||
|
items_key_id: string | null
|
||||||
|
enc_item_key: string | null
|
||||||
|
auth_hash: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Either } from '@standardnotes/common'
|
||||||
|
|
||||||
|
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||||
|
import { HttpResponse } from '../../Http/HttpResponse'
|
||||||
|
|
||||||
|
import { ListRevisionsResponseBody } from './ListRevisionsResponseBody'
|
||||||
|
|
||||||
|
export interface ListRevisionsResponse extends HttpResponse {
|
||||||
|
data: Either<ListRevisionsResponseBody, HttpErrorResponseBody>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface ListRevisionsResponseBody {
|
||||||
|
revisions: Array<{
|
||||||
|
uuid: string
|
||||||
|
content_type: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
required_role: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ export * from './Recovery/RecoveryKeyParamsResponse'
|
|||||||
export * from './Recovery/RecoveryKeyParamsResponseBody'
|
export * from './Recovery/RecoveryKeyParamsResponseBody'
|
||||||
export * from './Recovery/SignInWithRecoveryCodesResponse'
|
export * from './Recovery/SignInWithRecoveryCodesResponse'
|
||||||
export * from './Recovery/SignInWithRecoveryCodesResponseBody'
|
export * from './Recovery/SignInWithRecoveryCodesResponseBody'
|
||||||
|
export * from './Recovery/GenerateRecoveryCodesResponse'
|
||||||
|
export * from './Recovery/GenerateRecoveryCodesResponseBody'
|
||||||
|
export * from './Recovery/RecoveryKeyParamsResponse'
|
||||||
|
export * from './Recovery/RecoveryKeyParamsResponseBody'
|
||||||
|
export * from './Recovery/SignInWithRecoveryCodesResponse'
|
||||||
|
export * from './Recovery/SignInWithRecoveryCodesResponseBody'
|
||||||
export * from './Subscription/AppleIAPConfirmResponse'
|
export * from './Subscription/AppleIAPConfirmResponse'
|
||||||
export * from './Subscription/AppleIAPConfirmResponseBody'
|
export * from './Subscription/AppleIAPConfirmResponseBody'
|
||||||
export * from './Subscription/SubscriptionInviteAcceptResponse'
|
export * from './Subscription/SubscriptionInviteAcceptResponse'
|
||||||
|
|||||||
11
packages/api/src/Domain/Server/Revision/Paths.ts
Normal file
11
packages/api/src/Domain/Server/Revision/Paths.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const RevisionsPaths = {
|
||||||
|
listRevisions: (itemUuid: string) => `/v2/items/${itemUuid}/revisions`,
|
||||||
|
getRevision: (itemUuid: string, revisionUuid: string) => `/v2/items/${itemUuid}/revisions/${revisionUuid}`,
|
||||||
|
deleteRevision: (itemUuid: string, revisionUuid: string) => `/v2/items/${itemUuid}/revisions/${revisionUuid}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Paths = {
|
||||||
|
v2: {
|
||||||
|
...RevisionsPaths,
|
||||||
|
},
|
||||||
|
}
|
||||||
30
packages/api/src/Domain/Server/Revision/RevisionServer.ts
Normal file
30
packages/api/src/Domain/Server/Revision/RevisionServer.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
|
||||||
|
import { DeleteRevisionRequestParams, GetRevisionRequestParams, ListRevisionsRequestParams } from '../../Request'
|
||||||
|
import { DeleteRevisionResponse } from '../../Response/Revision/DeleteRevisionResponse'
|
||||||
|
import { GetRevisionResponse } from '../../Response/Revision/GetRevisionResponse'
|
||||||
|
import { ListRevisionsResponse } from '../../Response/Revision/ListRevisionsResponse'
|
||||||
|
|
||||||
|
import { Paths } from './Paths'
|
||||||
|
import { RevisionServerInterface } from './RevisionServerInterface'
|
||||||
|
|
||||||
|
export class RevisionServer implements RevisionServerInterface {
|
||||||
|
constructor(private httpService: HttpServiceInterface) {}
|
||||||
|
|
||||||
|
async listRevisions(params: ListRevisionsRequestParams): Promise<ListRevisionsResponse> {
|
||||||
|
const response = await this.httpService.get(Paths.v2.listRevisions(params.itemUuid))
|
||||||
|
|
||||||
|
return response as ListRevisionsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevision(params: GetRevisionRequestParams): Promise<GetRevisionResponse> {
|
||||||
|
const response = await this.httpService.get(Paths.v2.getRevision(params.itemUuid, params.revisionUuid))
|
||||||
|
|
||||||
|
return response as GetRevisionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRevision(params: DeleteRevisionRequestParams): Promise<DeleteRevisionResponse> {
|
||||||
|
const response = await this.httpService.delete(Paths.v2.deleteRevision(params.itemUuid, params.revisionUuid))
|
||||||
|
|
||||||
|
return response as DeleteRevisionResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { DeleteRevisionRequestParams } from '../../Request/Revision/DeleteRevisionRequestParams'
|
||||||
|
import { GetRevisionRequestParams } from '../../Request/Revision/GetRevisionRequestParams'
|
||||||
|
import { ListRevisionsRequestParams } from '../../Request/Revision/ListRevisionsRequestParams'
|
||||||
|
import { DeleteRevisionResponse } from '../../Response/Revision/DeleteRevisionResponse'
|
||||||
|
import { GetRevisionResponse } from '../../Response/Revision/GetRevisionResponse'
|
||||||
|
import { ListRevisionsResponse } from '../../Response/Revision/ListRevisionsResponse'
|
||||||
|
|
||||||
|
export interface RevisionServerInterface {
|
||||||
|
listRevisions(params: ListRevisionsRequestParams): Promise<ListRevisionsResponse>
|
||||||
|
getRevision(params: GetRevisionRequestParams): Promise<GetRevisionResponse>
|
||||||
|
deleteRevision(params: DeleteRevisionRequestParams): Promise<DeleteRevisionResponse>
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ export * from './Auth/AuthServer'
|
|||||||
export * from './Auth/AuthServerInterface'
|
export * from './Auth/AuthServerInterface'
|
||||||
export * from './Authenticator/AuthenticatorServer'
|
export * from './Authenticator/AuthenticatorServer'
|
||||||
export * from './Authenticator/AuthenticatorServerInterface'
|
export * from './Authenticator/AuthenticatorServerInterface'
|
||||||
|
export * from './Revision/RevisionServer'
|
||||||
|
export * from './Revision/RevisionServerInterface'
|
||||||
export * from './Subscription/SubscriptionServer'
|
export * from './Subscription/SubscriptionServer'
|
||||||
export * from './Subscription/SubscriptionServerInterface'
|
export * from './Subscription/SubscriptionServerInterface'
|
||||||
export * from './User/UserServer'
|
export * from './User/UserServer'
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { ClientDisplayableError } from '@standardnotes/responses'
|
|||||||
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
|
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
|
||||||
import { KeyedDecryptionSplit } from '../../Split/KeyedDecryptionSplit'
|
import { KeyedDecryptionSplit } from '../../Split/KeyedDecryptionSplit'
|
||||||
import { KeyedEncryptionSplit } from '../../Split/KeyedEncryptionSplit'
|
import { KeyedEncryptionSplit } from '../../Split/KeyedEncryptionSplit'
|
||||||
|
import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData'
|
||||||
|
import { LegacyAttachedData } from '../../Types/LegacyAttachedData'
|
||||||
|
import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData'
|
||||||
|
|
||||||
export interface EncryptionProviderInterface {
|
export interface EncryptionProviderInterface {
|
||||||
encryptSplitSingle(split: KeyedEncryptionSplit): Promise<EncryptedPayloadInterface>
|
encryptSplitSingle(split: KeyedEncryptionSplit): Promise<EncryptedPayloadInterface>
|
||||||
@@ -67,4 +70,7 @@ export interface EncryptionProviderInterface {
|
|||||||
reencryptItemsKeys(): Promise<void>
|
reencryptItemsKeys(): Promise<void>
|
||||||
getSureDefaultItemsKey(): ItemsKeyInterface
|
getSureDefaultItemsKey(): ItemsKeyInterface
|
||||||
getRootKeyParams(): Promise<SNRootKeyParams | undefined>
|
getRootKeyParams(): Promise<SNRootKeyParams | undefined>
|
||||||
|
getEmbeddedPayloadAuthenticatedData(
|
||||||
|
payload: EncryptedPayloadInterface,
|
||||||
|
): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { RoleName } from '@standardnotes/common'
|
|
||||||
|
|
||||||
export type RevisionListEntry = {
|
|
||||||
content_type: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
/** The uuid of the revision */
|
|
||||||
uuid: string
|
|
||||||
required_role: RoleName
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { HttpResponse } from '../Http/HttpResponse'
|
|
||||||
import { RevisionListEntry } from './RevisionListEntry'
|
|
||||||
|
|
||||||
export type RevisionListResponse = HttpResponse & { data: RevisionListEntry[] }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ContentType, Uuid } from '@standardnotes/common'
|
|
||||||
|
|
||||||
export type SingleRevision = {
|
|
||||||
auth_hash?: string
|
|
||||||
content_type: ContentType
|
|
||||||
content: string
|
|
||||||
created_at: string
|
|
||||||
enc_item_key: string
|
|
||||||
/** The uuid of the item this revision was created with */
|
|
||||||
item_uuid: string
|
|
||||||
items_key_id: string
|
|
||||||
updated_at: string
|
|
||||||
/** The uuid of the revision */
|
|
||||||
uuid: Uuid
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { HttpResponse } from '../Http/HttpResponse'
|
|
||||||
import { SingleRevision } from './SingleRevision'
|
|
||||||
|
|
||||||
export type SingleRevisionResponse = HttpResponse & {
|
|
||||||
data: SingleRevision
|
|
||||||
}
|
|
||||||
@@ -34,11 +34,7 @@ export * from './Item/ConflictType'
|
|||||||
export * from './Item/GetSingleItemResponse'
|
export * from './Item/GetSingleItemResponse'
|
||||||
export * from './Item/RawSyncData'
|
export * from './Item/RawSyncData'
|
||||||
export * from './Item/RawSyncResponse'
|
export * from './Item/RawSyncResponse'
|
||||||
export * from './Item/RevisionListEntry'
|
|
||||||
export * from './Item/RevisionListResponse'
|
|
||||||
export * from './Item/ServerItemResponse'
|
export * from './Item/ServerItemResponse'
|
||||||
export * from './Item/SingleRevision'
|
|
||||||
export * from './Item/SingleRevisionResponse'
|
|
||||||
export * from './Item/IntegrityPayload'
|
export * from './Item/IntegrityPayload'
|
||||||
export * from './Listed/ActionResponse'
|
export * from './Listed/ActionResponse'
|
||||||
export * from './Listed/ListedAccount'
|
export * from './Listed/ListedAccount'
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Uuid } from '@standardnotes/domain-core'
|
||||||
|
|
||||||
|
export interface RevisionClientInterface {
|
||||||
|
listRevisions(itemUuid: Uuid): Promise<
|
||||||
|
Array<{
|
||||||
|
uuid: string
|
||||||
|
content_type: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
required_role: string
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
deleteRevision(itemUuid: Uuid, revisionUuid: Uuid): Promise<string>
|
||||||
|
getRevision(
|
||||||
|
itemUuid: Uuid,
|
||||||
|
revisionUuid: Uuid,
|
||||||
|
): Promise<{
|
||||||
|
uuid: string
|
||||||
|
item_uuid: string
|
||||||
|
content: string | null
|
||||||
|
content_type: string
|
||||||
|
items_key_id: string | null
|
||||||
|
enc_item_key: string | null
|
||||||
|
auth_hash: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
} | null>
|
||||||
|
}
|
||||||
72
packages/services/src/Domain/Revision/RevisionManager.ts
Normal file
72
packages/services/src/Domain/Revision/RevisionManager.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { RevisionApiServiceInterface } from '@standardnotes/api'
|
||||||
|
import { Uuid } from '@standardnotes/domain-core'
|
||||||
|
|
||||||
|
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||||
|
import { AbstractService } from '../Service/AbstractService'
|
||||||
|
import { RevisionClientInterface } from './RevisionClientInterface'
|
||||||
|
|
||||||
|
export class RevisionManager extends AbstractService implements RevisionClientInterface {
|
||||||
|
constructor(
|
||||||
|
private revisionApiService: RevisionApiServiceInterface,
|
||||||
|
protected override internalEventBus: InternalEventBusInterface,
|
||||||
|
) {
|
||||||
|
super(internalEventBus)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRevisions(
|
||||||
|
itemUuid: Uuid,
|
||||||
|
): Promise<{ uuid: string; content_type: string; created_at: string; updated_at: string; required_role: string }[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.revisionApiService.listRevisions(itemUuid.value)
|
||||||
|
|
||||||
|
if (result.data.error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.revisions
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRevision(itemUuid: Uuid, revisionUuid: Uuid): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await this.revisionApiService.deleteRevision(itemUuid.value, revisionUuid.value)
|
||||||
|
|
||||||
|
if (result.data.error) {
|
||||||
|
return result.data.error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.message
|
||||||
|
} catch (error) {
|
||||||
|
return 'An error occurred while deleting the revision.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevision(
|
||||||
|
itemUuid: Uuid,
|
||||||
|
revisionUuid: Uuid,
|
||||||
|
): Promise<{
|
||||||
|
uuid: string
|
||||||
|
item_uuid: string
|
||||||
|
content: string | null
|
||||||
|
content_type: string
|
||||||
|
items_key_id: string | null
|
||||||
|
enc_item_key: string | null
|
||||||
|
auth_hash: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const result = await this.revisionApiService.getRevision(itemUuid.value, revisionUuid.value)
|
||||||
|
|
||||||
|
if (result.data.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.revision
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,8 +42,6 @@ export const API_MESSAGE_FAILED_SUBSCRIPTION_INFO = "Failed to get subscription'
|
|||||||
|
|
||||||
export const API_MESSAGE_FAILED_ACCESS_PURCHASE = 'Failed to access purchase flow.'
|
export const API_MESSAGE_FAILED_ACCESS_PURCHASE = 'Failed to access purchase flow.'
|
||||||
|
|
||||||
export const API_MESSAGE_FAILED_DELETE_REVISION = 'Failed to delete revision.'
|
|
||||||
|
|
||||||
export const API_MESSAGE_FAILED_OFFLINE_FEATURES = 'Failed to get offline features.'
|
export const API_MESSAGE_FAILED_OFFLINE_FEATURES = 'Failed to get offline features.'
|
||||||
export const API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING = `The extension you are attempting to install comes from an
|
export const API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING = `The extension you are attempting to install comes from an
|
||||||
untrusted source. Untrusted extensions may lower the security of your data. Do you want to continue?`
|
untrusted source. Untrusted extensions may lower the security of your data. Do you want to continue?`
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export * from './Preferences/PreferenceServiceInterface'
|
|||||||
export * from './Protection/MobileUnlockTiming'
|
export * from './Protection/MobileUnlockTiming'
|
||||||
export * from './Protection/ProtectionClientInterface'
|
export * from './Protection/ProtectionClientInterface'
|
||||||
export * from './Protection/TimingDisplayOption'
|
export * from './Protection/TimingDisplayOption'
|
||||||
|
export * from './Revision/RevisionClientInterface'
|
||||||
|
export * from './Revision/RevisionManager'
|
||||||
export * from './Service/AbstractService'
|
export * from './Service/AbstractService'
|
||||||
export * from './Service/ServiceInterface'
|
export * from './Service/ServiceInterface'
|
||||||
export * from './Session/SessionManagerResponse'
|
export * from './Session/SessionManagerResponse'
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
AuthServer,
|
AuthServer,
|
||||||
HttpService,
|
HttpService,
|
||||||
HttpServiceInterface,
|
HttpServiceInterface,
|
||||||
|
RevisionApiService,
|
||||||
|
RevisionServer,
|
||||||
SubscriptionApiService,
|
SubscriptionApiService,
|
||||||
SubscriptionApiServiceInterface,
|
SubscriptionApiServiceInterface,
|
||||||
SubscriptionServer,
|
SubscriptionServer,
|
||||||
@@ -71,6 +73,8 @@ import {
|
|||||||
AuthenticatorManager,
|
AuthenticatorManager,
|
||||||
AuthClientInterface,
|
AuthClientInterface,
|
||||||
AuthManager,
|
AuthManager,
|
||||||
|
RevisionClientInterface,
|
||||||
|
RevisionManager,
|
||||||
} from '@standardnotes/services'
|
} from '@standardnotes/services'
|
||||||
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
||||||
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
||||||
@@ -80,6 +84,7 @@ import {
|
|||||||
DecryptedItemInterface,
|
DecryptedItemInterface,
|
||||||
EncryptedItemInterface,
|
EncryptedItemInterface,
|
||||||
Environment,
|
Environment,
|
||||||
|
HistoryEntry,
|
||||||
ItemStream,
|
ItemStream,
|
||||||
Platform,
|
Platform,
|
||||||
} from '@standardnotes/models'
|
} from '@standardnotes/models'
|
||||||
@@ -100,6 +105,10 @@ import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthen
|
|||||||
import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators'
|
import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators'
|
||||||
import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
|
import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
|
||||||
import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator'
|
import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator'
|
||||||
|
import { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions'
|
||||||
|
import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision'
|
||||||
|
import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision'
|
||||||
|
import { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata'
|
||||||
|
|
||||||
/** How often to automatically sync, in milliseconds */
|
/** How often to automatically sync, in milliseconds */
|
||||||
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
|
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
|
||||||
@@ -176,6 +185,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
|
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
|
||||||
private declare authenticatorManager: AuthenticatorClientInterface
|
private declare authenticatorManager: AuthenticatorClientInterface
|
||||||
private declare authManager: AuthClientInterface
|
private declare authManager: AuthClientInterface
|
||||||
|
private declare revisionManager: RevisionClientInterface
|
||||||
|
|
||||||
private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes
|
private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes
|
||||||
private declare _getRecoveryCodes: GetRecoveryCodes
|
private declare _getRecoveryCodes: GetRecoveryCodes
|
||||||
@@ -183,6 +193,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
private declare _listAuthenticators: ListAuthenticators
|
private declare _listAuthenticators: ListAuthenticators
|
||||||
private declare _deleteAuthenticator: DeleteAuthenticator
|
private declare _deleteAuthenticator: DeleteAuthenticator
|
||||||
private declare _verifyAuthenticator: VerifyAuthenticator
|
private declare _verifyAuthenticator: VerifyAuthenticator
|
||||||
|
private declare _listRevisions: ListRevisions
|
||||||
|
private declare _getRevision: GetRevision
|
||||||
|
private declare _deleteRevision: DeleteRevision
|
||||||
|
|
||||||
private internalEventBus!: ExternalServices.InternalEventBusInterface
|
private internalEventBus!: ExternalServices.InternalEventBusInterface
|
||||||
|
|
||||||
@@ -289,6 +302,18 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
return this._verifyAuthenticator
|
return this._verifyAuthenticator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> {
|
||||||
|
return this._listRevisions
|
||||||
|
}
|
||||||
|
|
||||||
|
get getRevision(): UseCaseInterface<HistoryEntry> {
|
||||||
|
return this._getRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
get deleteRevision(): UseCaseInterface<void> {
|
||||||
|
return this._deleteRevision
|
||||||
|
}
|
||||||
|
|
||||||
public get files(): FilesClientInterface {
|
public get files(): FilesClientInterface {
|
||||||
return this.fileService
|
return this.fileService
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1213,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.createActionsManager()
|
this.createActionsManager()
|
||||||
this.createAuthenticatorManager()
|
this.createAuthenticatorManager()
|
||||||
this.createAuthManager()
|
this.createAuthManager()
|
||||||
|
this.createRevisionManager()
|
||||||
|
|
||||||
this.createUseCases()
|
this.createUseCases()
|
||||||
}
|
}
|
||||||
@@ -1239,12 +1265,16 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
;(this.legacySessionStorageMapper as unknown) = undefined
|
;(this.legacySessionStorageMapper as unknown) = undefined
|
||||||
;(this.authenticatorManager as unknown) = undefined
|
;(this.authenticatorManager as unknown) = undefined
|
||||||
;(this.authManager as unknown) = undefined
|
;(this.authManager as unknown) = undefined
|
||||||
|
;(this.revisionManager as unknown) = undefined
|
||||||
;(this._signInWithRecoveryCodes as unknown) = undefined
|
;(this._signInWithRecoveryCodes as unknown) = undefined
|
||||||
;(this._getRecoveryCodes as unknown) = undefined
|
;(this._getRecoveryCodes as unknown) = undefined
|
||||||
;(this._addAuthenticator as unknown) = undefined
|
;(this._addAuthenticator as unknown) = undefined
|
||||||
;(this._listAuthenticators as unknown) = undefined
|
;(this._listAuthenticators as unknown) = undefined
|
||||||
;(this._deleteAuthenticator as unknown) = undefined
|
;(this._deleteAuthenticator as unknown) = undefined
|
||||||
;(this._verifyAuthenticator as unknown) = undefined
|
;(this._verifyAuthenticator as unknown) = undefined
|
||||||
|
;(this._listRevisions as unknown) = undefined
|
||||||
|
;(this._getRevision as unknown) = undefined
|
||||||
|
;(this._deleteRevision as unknown) = undefined
|
||||||
|
|
||||||
this.services = []
|
this.services = []
|
||||||
}
|
}
|
||||||
@@ -1683,8 +1713,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.historyManager = new InternalServices.SNHistoryManager(
|
this.historyManager = new InternalServices.SNHistoryManager(
|
||||||
this.itemManager,
|
this.itemManager,
|
||||||
this.diskStorageService,
|
this.diskStorageService,
|
||||||
this.apiService,
|
|
||||||
this.protocolService,
|
|
||||||
this.deviceInterface,
|
this.deviceInterface,
|
||||||
this.internalEventBus,
|
this.internalEventBus,
|
||||||
)
|
)
|
||||||
@@ -1790,6 +1818,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.authManager = new AuthManager(authApiService, this.internalEventBus)
|
this.authManager = new AuthManager(authApiService, this.internalEventBus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createRevisionManager() {
|
||||||
|
const revisionServer = new RevisionServer(this.httpService)
|
||||||
|
|
||||||
|
const revisionApiService = new RevisionApiService(revisionServer)
|
||||||
|
|
||||||
|
this.revisionManager = new RevisionManager(revisionApiService, this.internalEventBus)
|
||||||
|
}
|
||||||
|
|
||||||
private createUseCases() {
|
private createUseCases() {
|
||||||
this._signInWithRecoveryCodes = new SignInWithRecoveryCodes(
|
this._signInWithRecoveryCodes = new SignInWithRecoveryCodes(
|
||||||
this.authManager,
|
this.authManager,
|
||||||
@@ -1815,5 +1851,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.authenticatorManager,
|
this.authenticatorManager,
|
||||||
this.options.u2fAuthenticatorVerificationPromptFunction,
|
this.options.u2fAuthenticatorVerificationPromptFunction,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this._listRevisions = new ListRevisions(this.revisionManager)
|
||||||
|
|
||||||
|
this._getRevision = new GetRevision(this.revisionManager, this.protocolService)
|
||||||
|
|
||||||
|
this._deleteRevision = new DeleteRevision(this.revisionManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/snjs/lib/Domain/Revision/Revision.ts
Normal file
11
packages/snjs/lib/Domain/Revision/Revision.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Revision {
|
||||||
|
uuid: string
|
||||||
|
item_uuid: string
|
||||||
|
content: string | null
|
||||||
|
content_type: string
|
||||||
|
items_key_id: string | null
|
||||||
|
enc_item_key: string | null
|
||||||
|
auth_hash: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
7
packages/snjs/lib/Domain/Revision/RevisionMetadata.ts
Normal file
7
packages/snjs/lib/Domain/Revision/RevisionMetadata.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface RevisionMetadata {
|
||||||
|
uuid: string
|
||||||
|
content_type: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
required_role: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
|
||||||
|
import { DeleteRevision } from './DeleteRevision'
|
||||||
|
|
||||||
|
describe('DeleteRevision', () => {
|
||||||
|
let revisionManager: RevisionClientInterface
|
||||||
|
|
||||||
|
const createUseCase = () => new DeleteRevision(revisionManager)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
revisionManager = {} as jest.Mocked<RevisionClientInterface>
|
||||||
|
revisionManager.deleteRevision = jest.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete revision', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if item uuid is invalid', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: 'invalid',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not delete revision: Given value is not a valid uuid: invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if revision uuid is invalid', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: 'invalid',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not delete revision: Given value is not a valid uuid: invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||||
|
|
||||||
|
import { DeleteRevisionDTO } from './DeleteRevisionDTO'
|
||||||
|
|
||||||
|
export class DeleteRevision implements UseCaseInterface<void> {
|
||||||
|
constructor(private revisionManager: RevisionClientInterface) {}
|
||||||
|
|
||||||
|
async execute(dto: DeleteRevisionDTO): Promise<Result<void>> {
|
||||||
|
const itemUuidOrError = Uuid.create(dto.itemUuid)
|
||||||
|
if (itemUuidOrError.isFailed()) {
|
||||||
|
return Result.fail(`Could not delete revision: ${itemUuidOrError.getError()}`)
|
||||||
|
}
|
||||||
|
const itemUuid = itemUuidOrError.getValue()
|
||||||
|
|
||||||
|
const revisionUuidOrError = Uuid.create(dto.revisionUuid)
|
||||||
|
if (revisionUuidOrError.isFailed()) {
|
||||||
|
return Result.fail(`Could not delete revision: ${revisionUuidOrError.getError()}`)
|
||||||
|
}
|
||||||
|
const revisionUuid = revisionUuidOrError.getValue()
|
||||||
|
|
||||||
|
await this.revisionManager.deleteRevision(itemUuid, revisionUuid)
|
||||||
|
|
||||||
|
return Result.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface DeleteRevisionDTO {
|
||||||
|
itemUuid: string
|
||||||
|
revisionUuid: string
|
||||||
|
}
|
||||||
158
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.spec.ts
Normal file
158
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { EncryptedPayloadInterface, HistoryEntry } from '@standardnotes/models'
|
||||||
|
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
jest.mock('@standardnotes/models', () => {
|
||||||
|
const original = jest.requireActual('@standardnotes/models')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
isRemotePayloadAllowed: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const isRemotePayloadAllowed = require('@standardnotes/models').isRemotePayloadAllowed
|
||||||
|
|
||||||
|
import { Revision } from '../../Revision/Revision'
|
||||||
|
|
||||||
|
import { GetRevision } from './GetRevision'
|
||||||
|
|
||||||
|
describe('GetRevision', () => {
|
||||||
|
let revisionManager: RevisionClientInterface
|
||||||
|
let protocolService: EncryptionProviderInterface
|
||||||
|
|
||||||
|
const createUseCase = () => new GetRevision(revisionManager, protocolService)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
revisionManager = {} as jest.Mocked<RevisionClientInterface>
|
||||||
|
revisionManager.getRevision = jest.fn().mockReturnValue({
|
||||||
|
uuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
item_uuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
content: '004:foobar',
|
||||||
|
content_type: 'Note',
|
||||||
|
items_key_id: 'foobar',
|
||||||
|
enc_item_key: 'foobar',
|
||||||
|
auth_hash: 'foobar',
|
||||||
|
created_at: '2021-01-01T00:00:00.000Z',
|
||||||
|
updated_at: '2021-01-01T00:00:00.000Z'
|
||||||
|
} as jest.Mocked<Revision>)
|
||||||
|
|
||||||
|
protocolService = {} as jest.Mocked<EncryptionProviderInterface>
|
||||||
|
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue({ u: '00000000-0000-0000-0000-000000000000' })
|
||||||
|
const encryptedPayload = {
|
||||||
|
content: 'foobar',
|
||||||
|
} as jest.Mocked<EncryptedPayloadInterface>
|
||||||
|
encryptedPayload.copy = jest.fn().mockReturnValue(encryptedPayload)
|
||||||
|
protocolService.decryptSplitSingle = jest.fn().mockReturnValue(encryptedPayload)
|
||||||
|
|
||||||
|
isRemotePayloadAllowed.mockImplementation(() => true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get revision', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(false)
|
||||||
|
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it should get a revision without uuid from embedded params', async () => {
|
||||||
|
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue({ u: undefined })
|
||||||
|
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(false)
|
||||||
|
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('it should get a revision without embedded params', async () => {
|
||||||
|
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue(undefined)
|
||||||
|
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(false)
|
||||||
|
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if item uuid is invalid', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: 'invalid',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not get revision: Given value is not a valid uuid: invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if revision uuid is invalid', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: 'invalid',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not get revision: Given value is not a valid uuid: invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if revision is not found', async () => {
|
||||||
|
revisionManager.getRevision = jest.fn().mockReturnValue(null)
|
||||||
|
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not get revision: Revision not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if there is an error in decrypting the revision', async () => {
|
||||||
|
const encryptedPayload = {
|
||||||
|
content: 'foobar',
|
||||||
|
errorDecrypting: true,
|
||||||
|
} as jest.Mocked<EncryptedPayloadInterface>
|
||||||
|
encryptedPayload.copy = jest.fn().mockReturnValue(encryptedPayload)
|
||||||
|
protocolService.decryptSplitSingle = jest.fn().mockReturnValue(encryptedPayload)
|
||||||
|
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not decrypt revision.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if remote payload is not allowed', async () => {
|
||||||
|
isRemotePayloadAllowed.mockImplementation(() => false)
|
||||||
|
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
80
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts
Normal file
80
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||||
|
import {
|
||||||
|
EncryptedPayload,
|
||||||
|
HistoryEntry,
|
||||||
|
isErrorDecryptingPayload,
|
||||||
|
isRemotePayloadAllowed,
|
||||||
|
NoteContent,
|
||||||
|
PayloadTimestampDefaults,
|
||||||
|
} from '@standardnotes/models'
|
||||||
|
import { ContentType } from '@standardnotes/common'
|
||||||
|
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||||
|
|
||||||
|
import { GetRevisionDTO } from './GetRevisionDTO'
|
||||||
|
|
||||||
|
export class GetRevision implements UseCaseInterface<HistoryEntry> {
|
||||||
|
constructor(private revisionManager: RevisionClientInterface, private protocolService: EncryptionProviderInterface) {}
|
||||||
|
|
||||||
|
async execute(dto: GetRevisionDTO): Promise<Result<HistoryEntry>> {
|
||||||
|
const itemUuidOrError = Uuid.create(dto.itemUuid)
|
||||||
|
if (itemUuidOrError.isFailed()) {
|
||||||
|
return Result.fail(`Could not get revision: ${itemUuidOrError.getError()}`)
|
||||||
|
}
|
||||||
|
const itemUuid = itemUuidOrError.getValue()
|
||||||
|
|
||||||
|
const revisionUuidOrError = Uuid.create(dto.revisionUuid)
|
||||||
|
if (revisionUuidOrError.isFailed()) {
|
||||||
|
return Result.fail(`Could not get revision: ${revisionUuidOrError.getError()}`)
|
||||||
|
}
|
||||||
|
const revisionUuid = revisionUuidOrError.getValue()
|
||||||
|
|
||||||
|
const revision = await this.revisionManager.getRevision(itemUuid, revisionUuid)
|
||||||
|
if (revision === null) {
|
||||||
|
return Result.fail('Could not get revision: Revision not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPayload = new EncryptedPayload({
|
||||||
|
...PayloadTimestampDefaults(),
|
||||||
|
uuid: revision.uuid,
|
||||||
|
content: revision.content as string,
|
||||||
|
enc_item_key: revision.enc_item_key as string,
|
||||||
|
items_key_id: revision.items_key_id as string,
|
||||||
|
auth_hash: revision.auth_hash as string,
|
||||||
|
content_type: revision.content_type as ContentType,
|
||||||
|
updated_at: new Date(revision.updated_at),
|
||||||
|
created_at: new Date(revision.created_at),
|
||||||
|
waitingForKey: false,
|
||||||
|
errorDecrypting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When an item is duplicated, its revisions also carry over to the newly created item.
|
||||||
|
* However since the new item has a different UUID than the source item, we must decrypt
|
||||||
|
* these olders revisions (which have not been mutated after copy) with the source item's
|
||||||
|
* uuid.
|
||||||
|
*/
|
||||||
|
const embeddedParams = this.protocolService.getEmbeddedPayloadAuthenticatedData(serverPayload)
|
||||||
|
const sourceItemUuid = embeddedParams?.u as string | undefined
|
||||||
|
|
||||||
|
const payload = serverPayload.copy({
|
||||||
|
uuid: sourceItemUuid || revision.item_uuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isRemotePayloadAllowed(payload)) {
|
||||||
|
return Result.fail(`Remote payload is disallowed: ${JSON.stringify(payload)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedPayload = new EncryptedPayload(payload)
|
||||||
|
|
||||||
|
const decryptedPayload = await this.protocolService.decryptSplitSingle<NoteContent>({
|
||||||
|
usesItemsKeyWithKeyLookup: { items: [encryptedPayload] },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isErrorDecryptingPayload(decryptedPayload)) {
|
||||||
|
return Result.fail('Could not decrypt revision.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(new HistoryEntry(decryptedPayload))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface GetRevisionDTO {
|
||||||
|
itemUuid: string
|
||||||
|
revisionUuid: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
|
||||||
|
import { ListRevisions } from './ListRevisions'
|
||||||
|
|
||||||
|
describe('ListRevisions', () => {
|
||||||
|
let revisionManager: RevisionClientInterface
|
||||||
|
|
||||||
|
const createUseCase = () => new ListRevisions(revisionManager)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
revisionManager = {} as jest.Mocked<RevisionClientInterface>
|
||||||
|
revisionManager.listRevisions = jest.fn().mockReturnValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should list revisions', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({ itemUuid: '00000000-0000-0000-0000-000000000000' })
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(false)
|
||||||
|
expect(result.getValue()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if item uuid is invalid', async () => {
|
||||||
|
const useCase = createUseCase()
|
||||||
|
|
||||||
|
const result = await useCase.execute({ itemUuid: 'invalid' })
|
||||||
|
|
||||||
|
expect(result.isFailed()).toBe(true)
|
||||||
|
expect(result.getError()).toEqual('Could not list item revisions: Given value is not a valid uuid: invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { RevisionClientInterface } from '@standardnotes/services'
|
||||||
|
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||||
|
|
||||||
|
import { RevisionMetadata } from '../../Revision/RevisionMetadata'
|
||||||
|
|
||||||
|
import { ListRevisionsDTO } from './ListRevisionsDTO'
|
||||||
|
|
||||||
|
export class ListRevisions implements UseCaseInterface<Array<RevisionMetadata>> {
|
||||||
|
constructor(private revisionManager: RevisionClientInterface) {}
|
||||||
|
|
||||||
|
async execute(dto: ListRevisionsDTO): Promise<Result<RevisionMetadata[]>> {
|
||||||
|
const itemUuidOrError = Uuid.create(dto.itemUuid)
|
||||||
|
if (itemUuidOrError.isFailed()) {
|
||||||
|
return Result.fail(`Could not list item revisions: ${itemUuidOrError.getError()}`)
|
||||||
|
}
|
||||||
|
const itemUuid = itemUuidOrError.getValue()
|
||||||
|
|
||||||
|
const revisions = await this.revisionManager.listRevisions(itemUuid)
|
||||||
|
|
||||||
|
return Result.ok(revisions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface ListRevisionsDTO {
|
||||||
|
itemUuid: string
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { HistoryEntry } from '@standardnotes/models'
|
||||||
import { UseCaseInterface } from '@standardnotes/domain-core'
|
import { UseCaseInterface } from '@standardnotes/domain-core'
|
||||||
|
|
||||||
|
import { RevisionMetadata } from '../Revision/RevisionMetadata'
|
||||||
|
|
||||||
export interface UseCaseContainerInterface {
|
export interface UseCaseContainerInterface {
|
||||||
get signInWithRecoveryCodes(): UseCaseInterface<void>
|
get signInWithRecoveryCodes(): UseCaseInterface<void>
|
||||||
get getRecoveryCodes(): UseCaseInterface<string>
|
get getRecoveryCodes(): UseCaseInterface<string>
|
||||||
@@ -7,4 +10,7 @@ export interface UseCaseContainerInterface {
|
|||||||
get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
|
get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
|
||||||
get deleteAuthenticator(): UseCaseInterface<void>
|
get deleteAuthenticator(): UseCaseInterface<void>
|
||||||
get verifyAuthenticator(): UseCaseInterface<void>
|
get verifyAuthenticator(): UseCaseInterface<void>
|
||||||
|
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>>
|
||||||
|
get getRevision(): UseCaseInterface<HistoryEntry>
|
||||||
|
get deleteRevision(): UseCaseInterface<void>
|
||||||
}
|
}
|
||||||
|
|||||||
2
packages/snjs/lib/Domain/index.ts
Normal file
2
packages/snjs/lib/Domain/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './Revision/Revision'
|
||||||
|
export * from './Revision/RevisionMetadata'
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS,
|
API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS,
|
||||||
API_MESSAGE_FAILED_ACCESS_PURCHASE,
|
API_MESSAGE_FAILED_ACCESS_PURCHASE,
|
||||||
API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
|
API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
|
||||||
API_MESSAGE_FAILED_DELETE_REVISION,
|
|
||||||
API_MESSAGE_FAILED_GET_SETTINGS,
|
API_MESSAGE_FAILED_GET_SETTINGS,
|
||||||
API_MESSAGE_FAILED_LISTED_REGISTRATION,
|
API_MESSAGE_FAILED_LISTED_REGISTRATION,
|
||||||
API_MESSAGE_FAILED_OFFLINE_ACTIVATION,
|
API_MESSAGE_FAILED_OFFLINE_ACTIVATION,
|
||||||
@@ -488,7 +487,7 @@ export class SNApiService
|
|||||||
return preprocessingError
|
return preprocessingError
|
||||||
}
|
}
|
||||||
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
|
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
|
||||||
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
|
const response: Responses.SessionListResponse | Responses.HttpResponse = await this.httpService
|
||||||
.deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken())
|
.deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken())
|
||||||
.catch((error: Responses.HttpResponse) => {
|
.catch((error: Responses.HttpResponse) => {
|
||||||
const errorResponse = error as Responses.HttpResponse
|
const errorResponse = error as Responses.HttpResponse
|
||||||
@@ -505,53 +504,6 @@ export class SNApiService
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async getItemRevisions(itemId: UuidString): Promise<Responses.RevisionListResponse | Responses.HttpResponse> {
|
|
||||||
const preprocessingError = this.preprocessingError()
|
|
||||||
if (preprocessingError) {
|
|
||||||
return preprocessingError
|
|
||||||
}
|
|
||||||
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
|
|
||||||
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
|
|
||||||
.getAbsolute(url, undefined, this.getSessionAccessToken())
|
|
||||||
.catch((errorResponse: Responses.HttpResponse) => {
|
|
||||||
this.preprocessAuthenticatedErrorResponse(errorResponse)
|
|
||||||
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
|
|
||||||
return this.refreshSessionThenRetryRequest({
|
|
||||||
verb: HttpVerb.Get,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_SYNC_FAIL)
|
|
||||||
})
|
|
||||||
this.processResponse(response)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRevision(
|
|
||||||
entry: Responses.RevisionListEntry,
|
|
||||||
itemId: UuidString,
|
|
||||||
): Promise<Responses.SingleRevisionResponse | Responses.HttpResponse> {
|
|
||||||
const preprocessingError = this.preprocessingError()
|
|
||||||
if (preprocessingError) {
|
|
||||||
return preprocessingError
|
|
||||||
}
|
|
||||||
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
|
|
||||||
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
|
|
||||||
.getAbsolute(url, undefined, this.getSessionAccessToken())
|
|
||||||
.catch((errorResponse: Responses.HttpResponse) => {
|
|
||||||
this.preprocessAuthenticatedErrorResponse(errorResponse)
|
|
||||||
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
|
|
||||||
return this.refreshSessionThenRetryRequest({
|
|
||||||
verb: HttpVerb.Get,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_SYNC_FAIL)
|
|
||||||
})
|
|
||||||
this.processResponse(response)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
|
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
|
||||||
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
|
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
|
||||||
const response = await this.httpService
|
const response = await this.httpService
|
||||||
@@ -652,20 +604,6 @@ export class SNApiService
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRevision(
|
|
||||||
itemUuid: UuidString,
|
|
||||||
entry: Responses.RevisionListEntry,
|
|
||||||
): Promise<Responses.MinimalHttpResponse> {
|
|
||||||
const url = joinPaths(this.host, Paths.v1.itemRevision(itemUuid, entry.uuid))
|
|
||||||
const response = await this.tokenRefreshableRequest({
|
|
||||||
verb: HttpVerb.Delete,
|
|
||||||
url,
|
|
||||||
fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION,
|
|
||||||
authentication: this.getSessionAccessToken(),
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
public downloadFeatureUrl(url: string): Promise<Responses.HttpResponse> {
|
public downloadFeatureUrl(url: string): Promise<Responses.HttpResponse> {
|
||||||
return this.request({
|
return this.request({
|
||||||
verb: HttpVerb.Get,
|
verb: HttpVerb.Get,
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ContentType, Uuid } from '@standardnotes/common'
|
import { ContentType } from '@standardnotes/common'
|
||||||
import { isNullOrUndefined, removeFromArray } from '@standardnotes/utils'
|
import { removeFromArray } from '@standardnotes/utils'
|
||||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||||
import { SNApiService } from '@Lib/Services/Api/ApiService'
|
|
||||||
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
|
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
|
||||||
import { UuidString } from '../../Types/UuidString'
|
import { UuidString } from '../../Types/UuidString'
|
||||||
import * as Models from '@standardnotes/models'
|
import * as Models from '@standardnotes/models'
|
||||||
import * as Responses from '@standardnotes/responses'
|
import { SNNote } from '@standardnotes/models'
|
||||||
import { isErrorDecryptingPayload, PayloadTimestampDefaults, SNNote } from '@standardnotes/models'
|
import { AbstractService, DeviceInterface, InternalEventBusInterface } from '@standardnotes/services'
|
||||||
import { AbstractService, EncryptionService, DeviceInterface, InternalEventBusInterface } from '@standardnotes/services'
|
|
||||||
|
|
||||||
/** The amount of revisions per item above which should call for an optimization. */
|
/** The amount of revisions per item above which should call for an optimization. */
|
||||||
const DefaultItemRevisionsThreshold = 20
|
const DefaultItemRevisionsThreshold = 20
|
||||||
@@ -46,8 +44,6 @@ export class SNHistoryManager extends AbstractService {
|
|||||||
constructor(
|
constructor(
|
||||||
private itemManager: ItemManager,
|
private itemManager: ItemManager,
|
||||||
private storageService: DiskStorageService,
|
private storageService: DiskStorageService,
|
||||||
private apiService: SNApiService,
|
|
||||||
private protocolService: EncryptionService,
|
|
||||||
public deviceInterface: DeviceInterface,
|
public deviceInterface: DeviceInterface,
|
||||||
protected override internalEventBus: InternalEventBusInterface,
|
protected override internalEventBus: InternalEventBusInterface,
|
||||||
) {
|
) {
|
||||||
@@ -120,78 +116,6 @@ export class SNHistoryManager extends AbstractService {
|
|||||||
return Object.freeze(copy)
|
return Object.freeze(copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a list of revisions from the server for an item. These revisions do not
|
|
||||||
* include the item's content. Instead, each revision's content must be fetched
|
|
||||||
* individually upon selection via `fetchRemoteRevision`.
|
|
||||||
*/
|
|
||||||
async remoteHistoryForItem(item: Models.SNNote): Promise<Responses.RevisionListEntry[] | undefined> {
|
|
||||||
const response = await this.apiService.getItemRevisions(item.uuid)
|
|
||||||
if (response.error || isNullOrUndefined(response.data)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return (response as Responses.RevisionListResponse).data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expands on a revision fetched via `remoteHistoryForItem` by getting a revision's
|
|
||||||
* complete fields (including encrypted content).
|
|
||||||
*/
|
|
||||||
async fetchRemoteRevision(
|
|
||||||
note: Models.SNNote,
|
|
||||||
entry: Responses.RevisionListEntry,
|
|
||||||
): Promise<Models.HistoryEntry | undefined> {
|
|
||||||
const revisionResponse = await this.apiService.getRevision(entry, note.uuid)
|
|
||||||
if (revisionResponse.error || isNullOrUndefined(revisionResponse.data)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const revision = (revisionResponse as Responses.SingleRevisionResponse).data
|
|
||||||
|
|
||||||
const serverPayload = new Models.EncryptedPayload({
|
|
||||||
...PayloadTimestampDefaults(),
|
|
||||||
...revision,
|
|
||||||
updated_at: new Date(revision.updated_at),
|
|
||||||
created_at: new Date(revision.created_at),
|
|
||||||
waitingForKey: false,
|
|
||||||
errorDecrypting: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When an item is duplicated, its revisions also carry over to the newly created item.
|
|
||||||
* However since the new item has a different UUID than the source item, we must decrypt
|
|
||||||
* these olders revisions (which have not been mutated after copy) with the source item's
|
|
||||||
* uuid.
|
|
||||||
*/
|
|
||||||
const embeddedParams = this.protocolService.getEmbeddedPayloadAuthenticatedData(serverPayload)
|
|
||||||
const sourceItemUuid = embeddedParams?.u as Uuid | undefined
|
|
||||||
|
|
||||||
const payload = serverPayload.copy({
|
|
||||||
uuid: sourceItemUuid || revision.item_uuid,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!Models.isRemotePayloadAllowed(payload)) {
|
|
||||||
console.error('Remote payload is disallowed', payload)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedPayload = new Models.EncryptedPayload(payload)
|
|
||||||
|
|
||||||
const decryptedPayload = await this.protocolService.decryptSplitSingle<Models.NoteContent>({
|
|
||||||
usesItemsKeyWithKeyLookup: { items: [encryptedPayload] },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isErrorDecryptingPayload(decryptedPayload)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Models.HistoryEntry(decryptedPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteRemoteRevision(note: SNNote, entry: Responses.RevisionListEntry): Promise<Responses.MinimalHttpResponse> {
|
|
||||||
const response = await this.apiService.deleteRevision(note.uuid, entry)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up if there are too many revisions. Note itemRevisionThreshold
|
* Clean up if there are too many revisions. Note itemRevisionThreshold
|
||||||
* is the amount of revisions which above, call for an optimization. An
|
* is the amount of revisions which above, call for an optimization. An
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './Application'
|
export * from './Application'
|
||||||
export * from './ApplicationGroup'
|
export * from './ApplicationGroup'
|
||||||
export * from './Client'
|
export * from './Client'
|
||||||
|
export * from './Domain'
|
||||||
export * from './Log'
|
export * from './Log'
|
||||||
export * from './Migrations'
|
export * from './Migrations'
|
||||||
export * from './Services'
|
export * from './Services'
|
||||||
|
|||||||
@@ -282,21 +282,30 @@ describe('history manager', () => {
|
|||||||
this.payloadManager = this.application.payloadManager
|
this.payloadManager = this.application.payloadManager
|
||||||
const item = await Factory.createSyncedNote(this.application)
|
const item = await Factory.createSyncedNote(this.application)
|
||||||
await this.application.syncService.sync(syncOptions)
|
await this.application.syncService.sync(syncOptions)
|
||||||
const itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||||
expect(itemHistory).to.be.undefined
|
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
expect(itemHistoryOrError.getValue().length).to.equal(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('create basic history entries 2', async function () {
|
it('create basic history entries 2', async function () {
|
||||||
const item = await Factory.createSyncedNote(this.application)
|
const item = await Factory.createSyncedNote(this.application)
|
||||||
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||||
|
|
||||||
|
let itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
let itemHistory = itemHistoryOrError.getValue()
|
||||||
|
|
||||||
/** Server history should save initial revision */
|
/** Server history should save initial revision */
|
||||||
expect(itemHistory).to.be.ok
|
|
||||||
expect(itemHistory.length).to.equal(1)
|
expect(itemHistory.length).to.equal(1)
|
||||||
|
|
||||||
/** Sync within 5 minutes, should not create a new entry */
|
/** Sync within 5 minutes, should not create a new entry */
|
||||||
await Factory.markDirtyAndSyncItem(this.application, item)
|
await Factory.markDirtyAndSyncItem(this.application, item)
|
||||||
itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
itemHistory = itemHistoryOrError.getValue()
|
||||||
expect(itemHistory.length).to.equal(1)
|
expect(itemHistory.length).to.equal(1)
|
||||||
|
|
||||||
/** Sync with different contents, should not create a new entry */
|
/** Sync with different contents, should not create a new entry */
|
||||||
@@ -309,7 +318,10 @@ describe('history manager', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
syncOptions,
|
syncOptions,
|
||||||
)
|
)
|
||||||
itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
itemHistory = itemHistoryOrError.getValue()
|
||||||
expect(itemHistory.length).to.equal(1)
|
expect(itemHistory.length).to.equal(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -328,11 +340,19 @@ describe('history manager', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
syncOptions,
|
syncOptions,
|
||||||
)
|
)
|
||||||
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||||
|
|
||||||
|
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
const itemHistory = itemHistoryOrError.getValue()
|
||||||
expect(itemHistory.length).to.equal(2)
|
expect(itemHistory.length).to.equal(2)
|
||||||
|
|
||||||
const oldestEntry = lastElement(itemHistory)
|
const oldestEntry = lastElement(itemHistory)
|
||||||
let revisionFromServer = await this.historyManager.fetchRemoteRevision(item, oldestEntry)
|
let revisionFromServerOrError = await this.application.getRevision.execute({ itemUuid: item.uuid, revisionUuid: oldestEntry.uuid })
|
||||||
|
expect(revisionFromServerOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
const revisionFromServer = revisionFromServerOrError.getValue()
|
||||||
expect(revisionFromServer).to.be.ok
|
expect(revisionFromServer).to.be.ok
|
||||||
|
|
||||||
let payloadFromServer = revisionFromServer.payload
|
let payloadFromServer = revisionFromServer.payload
|
||||||
@@ -349,9 +369,16 @@ describe('history manager', () => {
|
|||||||
await Factory.markDirtyAndSyncItem(this.application, note)
|
await Factory.markDirtyAndSyncItem(this.application, note)
|
||||||
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
||||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||||
|
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||||
|
|
||||||
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
const dupeHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||||
const dupeRevision = await this.historyManager.fetchRemoteRevision(dupe, dupeHistory[0])
|
expect(dupeHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
const dupeHistory = dupeHistoryOrError.getValue()
|
||||||
|
|
||||||
|
const dupeRevisionOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: dupeHistory[0].uuid })
|
||||||
|
expect(dupeRevisionOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
const dupeRevision = dupeRevisionOrError.getValue()
|
||||||
expect(dupeRevision.payload.uuid).to.equal(dupe.uuid)
|
expect(dupeRevision.payload.uuid).to.equal(dupe.uuid)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -376,8 +403,14 @@ describe('history manager', () => {
|
|||||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||||
|
|
||||||
const expectedRevisions = 3
|
const expectedRevisions = 3
|
||||||
const noteHistory = await this.historyManager.remoteHistoryForItem(note)
|
const noteHistoryOrError = await this.application.listRevisions.execute({ itemUuid: note.uuid })
|
||||||
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
expect(noteHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
const noteHistory = noteHistoryOrError.getValue()
|
||||||
|
|
||||||
|
const dupeHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||||
|
expect(dupeHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
const dupeHistory = dupeHistoryOrError.getValue()
|
||||||
|
|
||||||
expect(noteHistory.length).to.equal(expectedRevisions)
|
expect(noteHistory.length).to.equal(expectedRevisions)
|
||||||
expect(dupeHistory.length).to.equal(expectedRevisions)
|
expect(dupeHistory.length).to.equal(expectedRevisions)
|
||||||
})
|
})
|
||||||
@@ -398,11 +431,17 @@ describe('history manager', () => {
|
|||||||
|
|
||||||
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
||||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||||
const itemHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||||
|
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
const itemHistory = itemHistoryOrError.getValue()
|
||||||
expect(itemHistory.length).to.be.above(1)
|
expect(itemHistory.length).to.be.above(1)
|
||||||
const oldestRevision = lastElement(itemHistory)
|
const oldestRevision = lastElement(itemHistory)
|
||||||
|
|
||||||
const fetched = await this.historyManager.fetchRemoteRevision(dupe, oldestRevision)
|
const fetchedOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: oldestRevision.uuid })
|
||||||
|
expect(fetchedOrError.isFailed()).to.equal(false)
|
||||||
|
|
||||||
|
const fetched = fetchedOrError.getValue()
|
||||||
expect(fetched.payload.errorDecrypting).to.not.be.ok
|
expect(fetched.payload.errorDecrypting).to.not.be.ok
|
||||||
expect(fetched.payload.content.title).to.equal(changedText)
|
expect(fetched.payload.content.title).to.equal(changedText)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export async function storagePayloadCount(application) {
|
|||||||
* Controlled via docker/syncing-server-js.env
|
* Controlled via docker/syncing-server-js.env
|
||||||
*/
|
*/
|
||||||
export const ServerRevisionFrequency = 1.1
|
export const ServerRevisionFrequency = 1.1
|
||||||
|
export const ServerRevisionCreationDelay = 1.5
|
||||||
|
|
||||||
export function yesterday() {
|
export function yesterday() {
|
||||||
return new Date(new Date().setDate(new Date().getDate() - 1))
|
return new Date(new Date().setDate(new Date().getDate() - 1))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NoteHistoryController } from '@/Controllers/NoteHistory/NoteHistoryController'
|
import { NoteHistoryController } from '@/Controllers/NoteHistory/NoteHistoryController'
|
||||||
import { RevisionListEntry } from '@standardnotes/snjs'
|
import { RevisionMetadata } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import Button from '@/Components/Button/Button'
|
import Button from '@/Components/Button/Button'
|
||||||
@@ -36,7 +36,7 @@ const HistoryModalFooter = ({ dismissModal, noteHistoryController }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsDeletingRevision(true)
|
setIsDeletingRevision(true)
|
||||||
await deleteRemoteRevision(selectedEntry as RevisionListEntry)
|
await deleteRemoteRevision(selectedEntry as RevisionMetadata)
|
||||||
setIsDeletingRevision(false)
|
setIsDeletingRevision(false)
|
||||||
}, [deleteRemoteRevision, selectedEntry])
|
}, [deleteRemoteRevision, selectedEntry])
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ const HistoryModalFooter = ({ dismissModal, noteHistoryController }: Props) => {
|
|||||||
<Button className="py-1.35" label="Close" onClick={dismissModal} />
|
<Button className="py-1.35" label="Close" onClick={dismissModal} />
|
||||||
{selectedRevision && (
|
{selectedRevision && (
|
||||||
<>
|
<>
|
||||||
{(selectedEntry as RevisionListEntry).uuid && (
|
{(selectedEntry as RevisionMetadata).uuid && (
|
||||||
<Button className="md:ml-auto" onClick={deleteSelectedRevision}>
|
<Button className="md:ml-auto" onClick={deleteSelectedRevision}>
|
||||||
{isDeletingRevision ? <Spinner className="my-1 h-3 w-3" /> : 'Delete this revision'}
|
{isDeletingRevision ? <Spinner className="my-1 h-3 w-3" /> : 'Delete this revision'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
className={!(selectedEntry as RevisionListEntry).uuid ? 'md:ml-auto' : ''}
|
className={!(selectedEntry as RevisionMetadata).uuid ? 'md:ml-auto' : ''}
|
||||||
label="Restore as a copy"
|
label="Restore as a copy"
|
||||||
onClick={restoreAsCopy}
|
onClick={restoreAsCopy}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Icon from '@/Components/Icon/Icon'
|
|||||||
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
||||||
import HistoryListItem from './HistoryListItem'
|
import HistoryListItem from './HistoryListItem'
|
||||||
import { previewHistoryEntryTitle } from './utils'
|
import { previewHistoryEntryTitle } from './utils'
|
||||||
import { FeaturesClientInterface, RevisionListEntry } from '@standardnotes/snjs'
|
import { FeaturesClientInterface, RevisionMetadata, RoleName } from '@standardnotes/snjs'
|
||||||
import { NoteHistoryController } from '@/Controllers/NoteHistory/NoteHistoryController'
|
import { NoteHistoryController } from '@/Controllers/NoteHistory/NoteHistoryController'
|
||||||
import Spinner from '@/Components/Spinner/Spinner'
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||||
@@ -46,7 +46,7 @@ const RemoteHistoryList: FunctionComponent<RemoteHistoryListProps> = ({
|
|||||||
{group.entries.map((entry) => (
|
{group.entries.map((entry) => (
|
||||||
<HistoryListItem
|
<HistoryListItem
|
||||||
key={entry.uuid}
|
key={entry.uuid}
|
||||||
isSelected={(selectedEntry as RevisionListEntry)?.uuid === entry.uuid}
|
isSelected={(selectedEntry as RevisionMetadata)?.uuid === entry.uuid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void selectRemoteRevision(entry)
|
void selectRemoteRevision(entry)
|
||||||
onSelectRevision()
|
onSelectRevision()
|
||||||
@@ -54,7 +54,7 @@ const RemoteHistoryList: FunctionComponent<RemoteHistoryListProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-grow items-center justify-between">
|
<div className="flex flex-grow items-center justify-between">
|
||||||
<div>{previewHistoryEntryTitle(entry)}</div>
|
<div>{previewHistoryEntryTitle(entry)}</div>
|
||||||
{!features.hasMinimumRole(entry.required_role) && (
|
{!features.hasMinimumRole(entry.required_role as RoleName) && (
|
||||||
<Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />
|
<Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DAYS_IN_A_WEEK, DAYS_IN_A_YEAR } from '@/Constants/Constants'
|
import { DAYS_IN_A_WEEK, DAYS_IN_A_YEAR } from '@/Constants/Constants'
|
||||||
import { HistoryEntry, NoteHistoryEntry, RevisionListEntry } from '@standardnotes/snjs'
|
import { HistoryEntry, NoteHistoryEntry, RevisionMetadata } from '@standardnotes/snjs'
|
||||||
import { calculateDifferenceBetweenDatesInDays } from '../../Utils/CalculateDifferenceBetweenDatesInDays'
|
import { calculateDifferenceBetweenDatesInDays } from '../../Utils/CalculateDifferenceBetweenDatesInDays'
|
||||||
|
|
||||||
export type HistoryModalMobileTab = 'Content' | 'List'
|
export type HistoryModalMobileTab = 'Content' | 'List'
|
||||||
@@ -9,14 +9,14 @@ export type LegacyHistoryEntry = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RevisionEntry = RevisionListEntry | NoteHistoryEntry | LegacyHistoryEntry
|
type RevisionEntry = RevisionMetadata | NoteHistoryEntry | LegacyHistoryEntry
|
||||||
|
|
||||||
export type ListGroup<EntryType extends RevisionEntry> = {
|
export type ListGroup<EntryType extends RevisionEntry> = {
|
||||||
title: string
|
title: string
|
||||||
entries: EntryType[] | undefined
|
entries: EntryType[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RemoteRevisionListGroup = ListGroup<RevisionListEntry>
|
export type RemoteRevisionListGroup = ListGroup<RevisionMetadata>
|
||||||
export type SessionRevisionListGroup = ListGroup<NoteHistoryEntry>
|
export type SessionRevisionListGroup = ListGroup<NoteHistoryEntry>
|
||||||
|
|
||||||
export const formatDateAsMonthYearString = (date: Date) => {
|
export const formatDateAsMonthYearString = (date: Date) => {
|
||||||
@@ -28,7 +28,7 @@ export const formatDateAsMonthYearString = (date: Date) => {
|
|||||||
|
|
||||||
export const getGroupIndexForEntry = (entry: RevisionEntry, groups: ListGroup<RevisionEntry>[]) => {
|
export const getGroupIndexForEntry = (entry: RevisionEntry, groups: ListGroup<RevisionEntry>[]) => {
|
||||||
const todayAsDate = new Date()
|
const todayAsDate = new Date()
|
||||||
const entryDate = new Date((entry as RevisionListEntry).created_at ?? (entry as NoteHistoryEntry).payload.updated_at)
|
const entryDate = new Date((entry as RevisionMetadata).created_at ?? (entry as NoteHistoryEntry).payload.updated_at)
|
||||||
|
|
||||||
const differenceBetweenDatesInDays = calculateDifferenceBetweenDatesInDays(todayAsDate, entryDate)
|
const differenceBetweenDatesInDays = calculateDifferenceBetweenDatesInDays(todayAsDate, entryDate)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export const sortRevisionListIntoGroups = <EntryType extends RevisionEntry>(revi
|
|||||||
} else {
|
} else {
|
||||||
addBeforeLastGroup({
|
addBeforeLastGroup({
|
||||||
title: formatDateAsMonthYearString(
|
title: formatDateAsMonthYearString(
|
||||||
new Date((entry as RevisionListEntry).created_at ?? (entry as NoteHistoryEntry).payload.updated_at),
|
new Date((entry as RevisionMetadata).created_at ?? (entry as NoteHistoryEntry).payload.updated_at),
|
||||||
),
|
),
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
})
|
})
|
||||||
@@ -91,6 +91,6 @@ export const sortRevisionListIntoGroups = <EntryType extends RevisionEntry>(revi
|
|||||||
return sortedGroups
|
return sortedGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const previewHistoryEntryTitle = (revision: RevisionListEntry | LegacyHistoryEntry) => {
|
export const previewHistoryEntryTitle = (revision: RevisionMetadata | LegacyHistoryEntry) => {
|
||||||
return new Date(revision.created_at).toLocaleString()
|
return new Date(revision.created_at).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
NoteHistoryEntry,
|
NoteHistoryEntry,
|
||||||
PayloadEmitSource,
|
PayloadEmitSource,
|
||||||
RevisionListEntry,
|
RevisionMetadata,
|
||||||
|
RoleName,
|
||||||
SNNote,
|
SNNote,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { makeObservable, observable, action } from 'mobx'
|
import { makeObservable, observable, action } from 'mobx'
|
||||||
@@ -29,7 +30,7 @@ type LegacyHistory = Action[]
|
|||||||
|
|
||||||
type SelectedRevision = HistoryEntry | LegacyHistoryEntry | undefined
|
type SelectedRevision = HistoryEntry | LegacyHistoryEntry | undefined
|
||||||
|
|
||||||
type SelectedEntry = RevisionListEntry | NoteHistoryEntry | Action | undefined
|
type SelectedEntry = RevisionMetadata | NoteHistoryEntry | Action | undefined
|
||||||
|
|
||||||
export enum RevisionContentState {
|
export enum RevisionContentState {
|
||||||
Idle,
|
Idle,
|
||||||
@@ -114,12 +115,12 @@ export class NoteHistoryController {
|
|||||||
this.contentState = contentState
|
this.contentState = contentState
|
||||||
}
|
}
|
||||||
|
|
||||||
selectRemoteRevision = async (entry: RevisionListEntry) => {
|
selectRemoteRevision = async (entry: RevisionMetadata) => {
|
||||||
if (!this.note) {
|
if (!this.note) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.application.features.hasMinimumRole(entry.required_role)) {
|
if (!this.application.features.hasMinimumRole(entry.required_role as RoleName)) {
|
||||||
this.setContentState(RevisionContentState.NotEntitled)
|
this.setContentState(RevisionContentState.NotEntitled)
|
||||||
this.setSelectedRevision(undefined)
|
this.setSelectedRevision(undefined)
|
||||||
return
|
return
|
||||||
@@ -130,7 +131,14 @@ export class NoteHistoryController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.setSelectedEntry(entry)
|
this.setSelectedEntry(entry)
|
||||||
const remoteRevision = await this.application.historyManager.fetchRemoteRevision(this.note, entry)
|
const remoteRevisionOrError = await this.application.getRevision.execute({
|
||||||
|
itemUuid: this.note.uuid,
|
||||||
|
revisionUuid: entry.uuid,
|
||||||
|
})
|
||||||
|
if (remoteRevisionOrError.isFailed()) {
|
||||||
|
throw new Error(remoteRevisionOrError.getError())
|
||||||
|
}
|
||||||
|
const remoteRevision = remoteRevisionOrError.getValue()
|
||||||
this.setSelectedRevision(remoteRevision)
|
this.setSelectedRevision(remoteRevision)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
@@ -211,7 +219,7 @@ export class NoteHistoryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectPrevOrNextRemoteRevision = (revisionEntry: RevisionListEntry) => {
|
selectPrevOrNextRemoteRevision = (revisionEntry: RevisionMetadata) => {
|
||||||
const currentIndex = this.flattenedRemoteHistory.findIndex((entry) => entry?.uuid === revisionEntry.uuid)
|
const currentIndex = this.flattenedRemoteHistory.findIndex((entry) => entry?.uuid === revisionEntry.uuid)
|
||||||
|
|
||||||
const previousEntry = this.flattenedRemoteHistory[currentIndex - 1]
|
const previousEntry = this.flattenedRemoteHistory[currentIndex - 1]
|
||||||
@@ -234,9 +242,13 @@ export class NoteHistoryController {
|
|||||||
if (this.note) {
|
if (this.note) {
|
||||||
this.setIsFetchingRemoteHistory(true)
|
this.setIsFetchingRemoteHistory(true)
|
||||||
try {
|
try {
|
||||||
const initialRemoteHistory = await this.application.historyManager.remoteHistoryForItem(this.note)
|
const revisionsListOrError = await this.application.listRevisions.execute({ itemUuid: this.note.uuid })
|
||||||
|
if (revisionsListOrError.isFailed()) {
|
||||||
|
throw new Error(revisionsListOrError.getError())
|
||||||
|
}
|
||||||
|
const revisionsList = revisionsListOrError.getValue()
|
||||||
|
|
||||||
this.setRemoteHistory(sortRevisionListIntoGroups<RevisionListEntry>(initialRemoteHistory))
|
this.setRemoteHistory(sortRevisionListIntoGroups<RevisionMetadata>(revisionsList))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -341,7 +353,7 @@ export class NoteHistoryController {
|
|||||||
this.selectionController.selectItem(duplicatedItem.uuid).catch(console.error)
|
this.selectionController.selectItem(duplicatedItem.uuid).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRemoteRevision = async (revisionEntry: RevisionListEntry) => {
|
deleteRemoteRevision = async (revisionEntry: RevisionMetadata) => {
|
||||||
const shouldDelete = await this.application.alertService.confirm(
|
const shouldDelete = await this.application.alertService.confirm(
|
||||||
'Are you sure you want to delete this revision?',
|
'Are you sure you want to delete this revision?',
|
||||||
'Delete revision?',
|
'Delete revision?',
|
||||||
@@ -354,10 +366,12 @@ export class NoteHistoryController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.application.historyManager.deleteRemoteRevision(this.note, revisionEntry)
|
const deleteRevisionOrError = await this.application.deleteRevision.execute({
|
||||||
|
itemUuid: this.note.uuid,
|
||||||
if (response.error?.message) {
|
revisionUuid: revisionEntry.uuid,
|
||||||
throw new Error(response.error.message)
|
})
|
||||||
|
if (deleteRevisionOrError.isFailed()) {
|
||||||
|
throw new Error(deleteRevisionOrError.getError())
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
|
|||||||
Reference in New Issue
Block a user