internal: incomplete vault systems behind feature flag (#2340)

This commit is contained in:
Mo
2023-06-30 09:01:56 -05:00
committed by GitHub
parent d16e401bb9
commit b032eb9c9b
638 changed files with 20321 additions and 4813 deletions

View File

@@ -149,6 +149,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private declare subscriptionManager: SubscriptionClientInterface
private declare webSocketApiService: WebSocketApiServiceInterface
private declare webSocketServer: WebSocketServerInterface
private sessionManager!: InternalServices.SNSessionManager
private syncService!: InternalServices.SNSyncService
private challengeService!: InternalServices.ChallengeService
@@ -171,6 +172,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private integrityService!: ExternalServices.IntegrityService
private statusService!: ExternalServices.StatusService
private filesBackupService?: FilesBackupService
private vaultService!: ExternalServices.VaultServiceInterface
private contactService!: ExternalServices.ContactServiceInterface
private sharedVaultService!: ExternalServices.SharedVaultServiceInterface
private userEventService!: ExternalServices.UserEventService
private asymmetricMessageService!: ExternalServices.AsymmetricMessageService
private keySystemKeyManager!: ExternalServices.KeySystemKeyManager
private declare sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
private declare authenticatorManager: AuthenticatorClientInterface
@@ -313,7 +321,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.featuresService
}
public get items(): ExternalServices.ItemsClientInterface {
public get items(): ExternalServices.ItemManagerInterface {
return this.itemManager
}
@@ -373,6 +381,18 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.challengeService
}
public get vaults(): ExternalServices.VaultServiceInterface {
return this.vaultService
}
public get contacts(): ExternalServices.ContactServiceInterface {
return this.contactService
}
public get sharedVaults(): ExternalServices.SharedVaultServiceInterface {
return this.sharedVaultService
}
public computePrivateUsername(username: string): Promise<string | undefined> {
return ComputePrivateUsername(this.options.crypto, username)
}
@@ -534,6 +554,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
for (const service of this.services) {
await service.handleApplicationStage(stage)
}
this.internalEventBus.publish({
type: ApplicationEvent.ApplicationStageChanged,
payload: { stage } as ExternalServices.ApplicationStageChangedEventPayload,
})
}
/**
@@ -587,11 +612,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
} else if (event === ApplicationEvent.Launched) {
this.onLaunch()
}
for (const observer of this.eventHandlers.slice()) {
if ((observer.singleEvent && observer.singleEvent === event) || !observer.singleEvent) {
await observer.callback(event, data || {})
}
}
void this.migrationService.handleApplicationEvent(event)
}
@@ -637,6 +664,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
public async getAvailableSubscriptions(): Promise<
Responses.AvailableSubscriptions | Responses.ClientDisplayableError
> {
if (this.isThirdPartyHostUsed()) {
return ClientDisplayableError.FromString('Third party hosts do not support subscriptions.')
}
return this.sessionManager.getAvailableSubscriptions()
}
@@ -827,8 +857,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.diskStorageService.setValue(key, value, mode)
}
public getValue(key: string, mode?: ExternalServices.StorageValueModes): unknown {
return this.diskStorageService.getValue(key, mode)
public getValue<T>(key: string, mode?: ExternalServices.StorageValueModes): T {
return this.diskStorageService.getValue<T>(key, mode)
}
public async removeValue(key: string, mode?: ExternalServices.StorageValueModes): Promise<void> {
@@ -863,7 +893,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
}
}
public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void {
public addChallengeObserver(challenge: Challenge, observer: ExternalServices.ChallengeObserver): () => void {
return this.challengeService.addChallengeObserver(challenge, observer)
}
@@ -980,6 +1010,53 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
})
}
public async changeAndSaveItem<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: Models.PayloadEmitSource,
syncOptions?: ExternalServices.SyncOptions,
): Promise<DecryptedItemInterface | undefined> {
await this.mutator.changeItems(
[itemToLookupUuidFor],
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
emitSource,
)
await this.syncService.sync(syncOptions)
return this.itemManager.findItem(itemToLookupUuidFor.uuid)
}
public async changeAndSaveItems<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: Models.PayloadEmitSource,
syncOptions?: ExternalServices.SyncOptions,
): Promise<void> {
await this.mutator.changeItems(
itemsToLookupUuidsFor,
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
emitSource,
)
await this.syncService.sync(syncOptions)
}
public async importData(data: BackupFile, awaitSync = false): Promise<ExternalServices.ImportDataReturnType> {
const usecase = new ExternalServices.ImportDataUseCase(
this.itemManager,
this.syncService,
this.protectionService,
this.protocolService,
this.payloadManager,
this.challengeService,
this.historyManager,
)
return usecase.execute(data, awaitSync)
}
private async handleRevokedSession(): Promise<void> {
/**
* Because multiple API requests can come back at the same time
@@ -1148,9 +1225,16 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.createMappers()
this.createPayloadManager()
this.createItemManager()
this.createMutatorService()
this.createDiskStorageManager()
this.createUserEventService()
this.createInMemoryStorageManager()
this.createKeySystemKeyManager()
this.createProtocolService()
this.diskStorageService.provideEncryptionProvider(this.protocolService)
this.createChallengeService()
this.createLegacyHttpManager()
@@ -1185,7 +1269,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.createFileService()
this.createIntegrityService()
this.createMutatorService()
this.createListedService()
this.createActionsManager()
this.createAuthenticatorManager()
@@ -1193,6 +1277,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.createRevisionManager()
this.createUseCases()
this.createContactService()
this.createVaultService()
this.createSharedVaultService()
this.createAsymmetricMessageService()
}
private clearServices() {
@@ -1249,6 +1337,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this._listRevisions as unknown) = undefined
;(this._getRevision as unknown) = undefined
;(this._deleteRevision as unknown) = undefined
;(this.vaultService as unknown) = undefined
;(this.contactService as unknown) = undefined
;(this.sharedVaultService as unknown) = undefined
;(this.userEventService as unknown) = undefined
;(this.asymmetricMessageService as unknown) = undefined
;(this.keySystemKeyManager as unknown) = undefined
this.services = []
}
@@ -1270,6 +1364,71 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this.internalEventBus as unknown) = undefined
}
private createUserEventService(): void {
this.userEventService = new ExternalServices.UserEventService(this.internalEventBus)
this.services.push(this.userEventService)
}
private createAsymmetricMessageService() {
this.asymmetricMessageService = new ExternalServices.AsymmetricMessageService(
this.httpService,
this.protocolService,
this.contacts,
this.itemManager,
this.mutator,
this.syncService,
this.internalEventBus,
)
this.services.push(this.asymmetricMessageService)
}
private createContactService(): void {
this.contactService = new ExternalServices.ContactService(
this.syncService,
this.itemManager,
this.mutator,
this.sessionManager,
this.options.crypto,
this.user,
this.protocolService,
this.singletonManager,
this.internalEventBus,
)
this.services.push(this.contactService)
}
private createSharedVaultService(): void {
this.sharedVaultService = new ExternalServices.SharedVaultService(
this.httpService,
this.syncService,
this.itemManager,
this.mutator,
this.protocolService,
this.sessions,
this.contactService,
this.files,
this.vaults,
this.storage,
this.internalEventBus,
)
this.services.push(this.sharedVaultService)
}
private createVaultService(): void {
this.vaultService = new ExternalServices.VaultService(
this.syncService,
this.itemManager,
this.mutator,
this.protocolService,
this.files,
this.alertService,
this.internalEventBus,
)
this.services.push(this.vaultService)
}
private createListedService(): void {
this.listedService = new InternalServices.ListedService(
this.apiService,
@@ -1278,6 +1437,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.deprecatedHttpService,
this.protectionService,
this.mutator,
this.sync,
this.internalEventBus,
)
this.services.push(this.listedService)
@@ -1286,10 +1446,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createFileService() {
this.fileService = new FileService(
this.apiService,
this.itemManager,
this.mutator,
this.syncService,
this.protocolService,
this.challengeService,
this.httpService,
this.alertService,
this.options.crypto,
this.internalEventBus,
@@ -1315,6 +1476,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.diskStorageService,
this.apiService,
this.itemManager,
this.mutator,
this.webSocketsService,
this.settingsService,
this.userService,
@@ -1366,6 +1528,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
sessionManager: this.sessionManager,
challengeService: this.challengeService,
itemManager: this.itemManager,
mutator: this.mutator,
singletonManager: this.singletonManager,
featuresService: this.featuresService,
environment: this.environment,
@@ -1453,6 +1616,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createComponentManager() {
this.componentManagerService = new InternalServices.SNComponentManager(
this.itemManager,
this.mutator,
this.syncService,
this.featuresService,
this.preferencesService,
@@ -1508,6 +1672,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createSingletonManager() {
this.singletonManager = new InternalServices.SNSingletonManager(
this.itemManager,
this.mutator,
this.payloadManager,
this.syncService,
this.internalEventBus,
@@ -1531,9 +1696,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createProtocolService() {
this.protocolService = new EncryptionService(
this.itemManager,
this.mutator,
this.payloadManager,
this.deviceInterface,
this.diskStorageService,
this.keySystemKeyManager,
this.identifier,
this.options.crypto,
this.internalEventBus,
@@ -1548,6 +1715,17 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.services.push(this.protocolService)
}
private createKeySystemKeyManager() {
this.keySystemKeyManager = new ExternalServices.KeySystemKeyManager(
this.itemManager,
this.mutator,
this.storage,
this.internalEventBus,
)
this.services.push(this.keySystemKeyManager)
}
private createKeyRecoveryService() {
this.keyRecoveryService = new InternalServices.SNKeyRecoveryService(
this.itemManager,
@@ -1582,7 +1760,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.serviceObservers.push(
this.sessionManager.addEventObserver(async (event) => {
switch (event) {
case InternalServices.SessionEvent.Restored: {
case ExternalServices.SessionEvent.Restored: {
void (async () => {
await this.sync.sync({ sourceDescription: 'Session restored pre key creation' })
if (this.protocolService.needsNewRootKeyBasedItemsKey()) {
@@ -1593,10 +1771,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
})()
break
}
case InternalServices.SessionEvent.Revoked: {
case ExternalServices.SessionEvent.Revoked: {
await this.handleRevokedSession()
break
}
case ExternalServices.SessionEvent.UserKeyPairChanged:
break
default: {
Utils.assertUnreachable(event)
}
@@ -1655,6 +1835,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createProtectionService() {
this.protectionService = new InternalServices.SNProtectionService(
this.protocolService,
this.mutator,
this.challengeService,
this.diskStorageService,
this.internalEventBus,
@@ -1701,6 +1882,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.preferencesService = new InternalServices.SNPreferencesService(
this.singletonManager,
this.itemManager,
this.mutator,
this.syncService,
this.internalEventBus,
)
@@ -1734,13 +1916,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private createMutatorService() {
this.mutatorService = new InternalServices.MutatorService(
this.itemManager,
this.syncService,
this.protectionService,
this.protocolService,
this.payloadManager,
this.challengeService,
this.componentManagerService,
this.historyManager,
this.alertService,
this.internalEventBus,
)
this.services.push(this.mutatorService)

View File

@@ -5,7 +5,7 @@ export function applicationEventForSyncEvent(syncEvent: SyncEvent) {
return (
{
[SyncEvent.SyncCompletedWithAllItemsUploaded]: ApplicationEvent.CompletedFullSync,
[SyncEvent.SingleRoundTripSyncCompleted]: ApplicationEvent.CompletedIncrementalSync,
[SyncEvent.PaginatedSyncRequestCompleted]: ApplicationEvent.CompletedIncrementalSync,
[SyncEvent.SyncError]: ApplicationEvent.FailedSync,
[SyncEvent.SyncTakingTooLong]: ApplicationEvent.HighLatencySync,
[SyncEvent.EnterOutOfSync]: ApplicationEvent.EnteredOutOfSync,
@@ -14,7 +14,7 @@ export function applicationEventForSyncEvent(syncEvent: SyncEvent) {
[SyncEvent.MajorDataChange]: ApplicationEvent.MajorDataChange,
[SyncEvent.LocalDataIncrementalLoad]: ApplicationEvent.LocalDataIncrementalLoad,
[SyncEvent.StatusChanged]: ApplicationEvent.SyncStatusChanged,
[SyncEvent.SyncWillBegin]: ApplicationEvent.WillSync,
[SyncEvent.SyncDidBeginProcessing]: ApplicationEvent.WillSync,
[SyncEvent.InvalidSession]: ApplicationEvent.InvalidSyncSession,
[SyncEvent.DatabaseReadError]: ApplicationEvent.LocalDatabaseReadError,
[SyncEvent.DatabaseWriteError]: ApplicationEvent.LocalDatabaseWriteError,

View File

@@ -1,12 +1,12 @@
import { DecryptedItemInterface } from '@standardnotes/models'
import { SNApplication } from './Application'
import { ApplicationInterface } from '@standardnotes/services'
/** Keeps an item reference up to date with changes */
export class LiveItem<T extends DecryptedItemInterface> {
public item: T
private removeObserver: () => void
constructor(uuid: string, application: SNApplication, onChange?: (item: T) => void) {
constructor(uuid: string, application: ApplicationInterface, onChange?: (item: T) => void) {
this.item = application.items.findSureItem(uuid)
onChange && onChange(this.item)

View File

@@ -1,3 +1,4 @@
import { ServerItemResponse } from '@standardnotes/responses'
import { RevisionClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import {
@@ -50,6 +51,8 @@ export class GetRevision implements UseCaseInterface<HistoryEntry> {
content_type: revision.content_type as ContentType,
updated_at: new Date(revision.updated_at),
created_at: new Date(revision.created_at),
key_system_identifier: revision.key_system_identifier ?? undefined,
shared_vault_uuid: revision.shared_vault_uuid ?? undefined,
waitingForKey: false,
errorDecrypting: false,
})
@@ -67,7 +70,7 @@ export class GetRevision implements UseCaseInterface<HistoryEntry> {
uuid: sourceItemUuid || revision.item_uuid,
})
if (!isRemotePayloadAllowed(payload)) {
if (!isRemotePayloadAllowed(payload as ServerItemResponse)) {
return Result.fail(`Remote payload is disallowed: ${JSON.stringify(payload)}`)
}

View File

@@ -0,0 +1,3 @@
/** Declared in webpack config */
declare const __IS_DEV__: boolean
export const isDev = __IS_DEV__

View File

@@ -1,31 +1,37 @@
import { ItemManager } from '@Lib/Services'
import { TagsToFoldersMigrationApplicator } from './TagsToFolders'
import { MutatorClientInterface } from '@standardnotes/services'
describe('folders component to hierarchy', () => {
let itemManager: ItemManager
let mutator: MutatorClientInterface
let changeItemMock: jest.Mock
let findOrCreateTagParentChainMock: jest.Mock
const itemManagerMock = (tagTitles: string[]) => {
const mockTag = (title: string) => ({
title,
uuid: title,
parentId: undefined,
})
beforeEach(() => {
itemManager = {} as unknown as jest.Mocked<ItemManager>
const mock = {
getItems: jest.fn().mockReturnValue(tagTitles.map(mockTag)),
findOrCreateTagParentChain: jest.fn(),
changeItem: jest.fn(),
}
mutator = {} as unknown as jest.Mocked<MutatorClientInterface>
return mock
}
changeItemMock = mutator.changeItem = jest.fn()
findOrCreateTagParentChainMock = mutator.findOrCreateTagParentChain = jest.fn()
})
describe('folders component to hierarchy', () => {
it('should produce a valid hierarchy in the simple case', async () => {
const titles = ['a', 'a.b', 'a.b.c']
itemManager.getItems
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
@@ -39,11 +45,11 @@ describe('folders component to hierarchy', () => {
it('should not touch flat hierarchies', async () => {
const titles = ['a', 'x', 'y', 'z']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
@@ -53,11 +59,11 @@ describe('folders component to hierarchy', () => {
it('should work despite cloned tags', async () => {
const titles = ['a.b', 'c', 'a.b']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
@@ -71,11 +77,11 @@ describe('folders component to hierarchy', () => {
it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async () => {
const titles = ['y.2', 'w.3', 'y']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['w'])
@@ -89,11 +95,11 @@ describe('folders component to hierarchy', () => {
it('skip prefixed names', async () => {
const titles = ['.something', '.something...something']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
expect(changeItemCalls.length).toEqual(0)
@@ -109,11 +115,11 @@ describe('folders component to hierarchy', () => {
'something..another.thing..anyway',
]
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag))
await TagsToFoldersMigrationApplicator.run(itemManager, mutator)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls
const changeItemCalls = changeItemMock.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(1)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a', 'b'])

View File

@@ -1,3 +1,4 @@
import { MutatorClientInterface } from '@standardnotes/services'
import { SNTag, TagMutator, TagFolderDelimitter } from '@standardnotes/models'
import { ItemManager } from '@Lib/Services'
import { lastElement, sortByKey, withoutLastElement } from '@standardnotes/utils'
@@ -15,7 +16,7 @@ export class TagsToFoldersMigrationApplicator {
return false
}
public static async run(itemManager: ItemManager): Promise<void> {
public static async run(itemManager: ItemManager, mutator: MutatorClientInterface): Promise<void> {
const tags = itemManager.getItems(ContentType.Tag) as SNTag[]
const sortedTags = sortByKey(tags, 'title')
@@ -36,9 +37,9 @@ export class TagsToFoldersMigrationApplicator {
return
}
const parent = await itemManager.findOrCreateTagParentChain(parents)
const parent = await mutator.findOrCreateTagParentChain(parents)
await itemManager.changeItem(tag, (mutator: TagMutator) => {
await mutator.changeItem(tag, (mutator: TagMutator) => {
mutator.title = newTitle
if (parent) {

View File

@@ -1,5 +1,10 @@
import { AnyKeyParamsContent, KeyParamsContent004 } from '@standardnotes/common'
import { EncryptedPayload, EncryptedTransferPayload, isErrorDecryptingPayload } from '@standardnotes/models'
import {
EncryptedPayload,
EncryptedTransferPayload,
isErrorDecryptingPayload,
ContentTypeUsesRootKeyEncryption,
} from '@standardnotes/models'
import { PreviousSnjsVersion1_0_0, PreviousSnjsVersion2_0_0, SnjsVersion } from '../Version'
import { Migration } from '@Lib/Migrations/Migration'
import {
@@ -16,7 +21,6 @@ import {
import { assert } from '@standardnotes/utils'
import { CreateReader } from './StorageReaders/Functions'
import { StorageReader } from './StorageReaders/Reader'
import { ContentTypeUsesRootKeyEncryption } from '@standardnotes/encryption'
/** A key that was briefly present in Snjs version 2.0.0 but removed in 2.0.1 */
const LastMigrationTimeStampKey2_0_0 = 'last_migration_timestamp'

View File

@@ -1,6 +1,11 @@
import { BackupServiceInterface } from '@standardnotes/files'
import { Environment, Platform } from '@standardnotes/models'
import { DeviceInterface, InternalEventBusInterface, EncryptionService } from '@standardnotes/services'
import {
DeviceInterface,
InternalEventBusInterface,
EncryptionService,
MutatorClientInterface,
} from '@standardnotes/services'
import { SNSessionManager } from '../Services/Session/SessionManager'
import { ApplicationIdentifier } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
@@ -15,6 +20,7 @@ export type MigrationServices = {
sessionManager: SNSessionManager
backups?: BackupServiceInterface
itemManager: ItemManager
mutator: MutatorClientInterface
singletonManager: SNSingletonManager
featuresService: SNFeaturesService
environment: Environment

View File

@@ -20,7 +20,7 @@ export class Migration2_20_0 extends Migration {
for (const item of items) {
this.services.itemManager.removeItemLocally(item)
await this.services.storageService.deletePayloadWithId(item.uuid)
await this.services.storageService.deletePayloadWithUuid(item.uuid)
}
}
}

View File

@@ -20,7 +20,7 @@ export class Migration2_36_0 extends Migration {
for (const item of items) {
this.services.itemManager.removeItemLocally(item)
await this.services.storageService.deletePayloadWithId(item.uuid)
await this.services.storageService.deletePayloadWithUuid(item.uuid)
}
}
}

View File

@@ -24,7 +24,7 @@ export class Migration2_42_0 extends Migration {
})
for (const theme of themes) {
await this.services.itemManager.setItemToBeDeleted(theme)
await this.services.mutator.setItemToBeDeleted(theme)
}
}
}

View File

@@ -26,7 +26,7 @@ export class Migration2_7_0 extends Migration {
const batchMgrSingleton = this.services.singletonManager.findSingleton(ContentType.Component, batchMgrPred)
if (batchMgrSingleton) {
await this.services.itemManager.setItemToBeDeleted(batchMgrSingleton)
await this.services.mutator.setItemToBeDeleted(batchMgrSingleton)
}
}
}

View File

@@ -8,7 +8,6 @@ import {
ItemsServerInterface,
StorageKey,
ApiServiceEvent,
DiagnosticInfo,
KeyValueStoreInterface,
API_MESSAGE_GENERIC_SYNC_FAIL,
API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL,
@@ -30,8 +29,8 @@ import {
API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS,
ApiServiceEventData,
} from '@standardnotes/services'
import { FilesApiInterface } from '@standardnotes/files'
import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models'
import { DownloadFileParams, FileOwnershipType, FilesApiInterface } from '@standardnotes/files'
import { ServerSyncPushContextualPayload, SNFeatureRepo } from '@standardnotes/models'
import {
User,
HttpStatusCode,
@@ -72,6 +71,8 @@ import {
HttpErrorResponse,
HttpSuccessResponse,
isErrorResponse,
ValetTokenOperation,
MoveFileResponse,
} from '@standardnotes/responses'
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
import { HttpServiceInterface } from '@standardnotes/api'
@@ -103,7 +104,6 @@ export class SNApiService
{
private session: Session | LegacySession | null
public user?: User
private registering = false
private authenticating = false
private changing = false
private refreshingSession = false
@@ -210,7 +210,7 @@ export class SNApiService
}
private errorResponseWithFallbackMessage(response: HttpErrorResponse, message: string): HttpErrorResponse {
if (!response.data.error.message) {
if (response.data.error && !response.data.error.message) {
response.data.error.message = message
}
@@ -369,9 +369,10 @@ export class SNApiService
async sync(
payloads: ServerSyncPushContextualPayload[],
lastSyncToken: string,
paginationToken: string,
lastSyncToken: string | undefined,
paginationToken: string | undefined,
limit: number,
sharedVaultUuids?: string[],
): Promise<HttpResponse<RawSyncResponse>> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
@@ -383,6 +384,7 @@ export class SNApiService
[ApiEndpointParam.LastSyncToken]: lastSyncToken,
[ApiEndpointParam.PaginationToken]: paginationToken,
[ApiEndpointParam.SyncDlLimit]: limit,
[ApiEndpointParam.SharedVaultUuids]: sharedVaultUuids,
})
const response = await this.httpService.post<RawSyncResponse>(path, params, this.getSessionAccessToken())
@@ -686,12 +688,12 @@ export class SNApiService
})
}
public async createFileValetToken(
public async createUserFileValetToken(
remoteIdentifier: string,
operation: 'write' | 'read' | 'delete',
operation: ValetTokenOperation,
unencryptedFileSize?: number,
): Promise<string | ClientDisplayableError> {
const url = joinPaths(this.host, Paths.v1.createFileValetToken)
const url = joinPaths(this.host, Paths.v1.createUserFileValetToken)
const params: CreateValetTokenPayload = {
operation,
@@ -717,40 +719,60 @@ export class SNApiService
return response.data?.valetToken
}
public async startUploadSession(apiToken: string): Promise<HttpResponse<StartUploadSessionResponse>> {
const url = joinPaths(this.getFilesHost(), Paths.v1.startUploadSession)
public async startUploadSession(
valetToken: string,
ownershipType: FileOwnershipType,
): Promise<HttpResponse<StartUploadSessionResponse>> {
const url = joinPaths(
this.getFilesHost(),
ownershipType === 'user' ? Paths.v1.startUploadSession : Paths.v1.startSharedVaultUploadSession,
)
return this.tokenRefreshableRequest({
verb: HttpVerb.Post,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
customHeaders: [{ key: 'x-valet-token', value: valetToken }],
fallbackErrorMessage: Strings.Network.Files.FailedStartUploadSession,
})
}
public async deleteFile(apiToken: string): Promise<HttpResponse<StartUploadSessionResponse>> {
const url = joinPaths(this.getFilesHost(), Paths.v1.deleteFile)
public async deleteFile(
valetToken: string,
ownershipType: FileOwnershipType,
): Promise<HttpResponse<StartUploadSessionResponse>> {
const url = joinPaths(
this.getFilesHost(),
ownershipType === 'user' ? Paths.v1.deleteFile : Paths.v1.deleteSharedVaultFile,
)
return this.tokenRefreshableRequest({
verb: HttpVerb.Delete,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
customHeaders: [{ key: 'x-valet-token', value: valetToken }],
fallbackErrorMessage: Strings.Network.Files.FailedDeleteFile,
})
}
public async uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise<boolean> {
public async uploadFileBytes(
valetToken: string,
ownershipType: FileOwnershipType,
chunkId: number,
encryptedBytes: Uint8Array,
): Promise<boolean> {
if (chunkId === 0) {
throw Error('chunkId must start with 1')
}
const url = joinPaths(this.getFilesHost(), Paths.v1.uploadFileChunk)
const url = joinPaths(
this.getFilesHost(),
ownershipType === 'user' ? Paths.v1.uploadFileChunk : Paths.v1.uploadSharedVaultFileChunk,
)
const response = await this.tokenRefreshableRequest<UploadFileChunkResponse>({
verb: HttpVerb.Post,
url,
rawBytes: encryptedBytes,
customHeaders: [
{ key: 'x-valet-token', value: apiToken },
{ key: 'x-valet-token', value: valetToken },
{ key: 'x-chunk-id', value: chunkId.toString() },
{ key: 'Content-Type', value: 'application/octet-stream' },
],
@@ -764,13 +786,16 @@ export class SNApiService
return response.data.success
}
public async closeUploadSession(apiToken: string): Promise<boolean> {
const url = joinPaths(this.getFilesHost(), Paths.v1.closeUploadSession)
public async closeUploadSession(valetToken: string, ownershipType: FileOwnershipType): Promise<boolean> {
const url = joinPaths(
this.getFilesHost(),
ownershipType === 'user' ? Paths.v1.closeUploadSession : Paths.v1.closeSharedVaultUploadSession,
)
const response = await this.tokenRefreshableRequest<CloseUploadSessionResponse>({
verb: HttpVerb.Post,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
customHeaders: [{ key: 'x-valet-token', value: valetToken }],
fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession,
})
@@ -781,33 +806,61 @@ export class SNApiService
return response.data.success
}
public getFilesDownloadUrl(): string {
return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk)
public async moveFile(valetToken: string): Promise<boolean> {
const url = joinPaths(this.getFilesHost(), Paths.v1.moveFile)
const response = await this.tokenRefreshableRequest<MoveFileResponse>({
verb: HttpVerb.Post,
url,
customHeaders: [{ key: 'x-valet-token', value: valetToken }],
fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession,
})
if (isErrorResponse(response)) {
return false
}
return response.data.success
}
public async downloadFile(
file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] },
chunkIndex = 0,
apiToken: string,
contentRangeStart: number,
onBytesReceived: (bytes: Uint8Array) => Promise<void>,
): Promise<ClientDisplayableError | undefined> {
const url = this.getFilesDownloadUrl()
public getFilesDownloadUrl(ownershipType: FileOwnershipType): string {
if (ownershipType === 'user') {
return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk)
} else if (ownershipType === 'shared-vault') {
return joinPaths(this.getFilesHost(), Paths.v1.downloadSharedVaultFileChunk)
} else {
throw Error('Invalid download type')
}
}
public async downloadFile({
file,
chunkIndex,
valetToken,
ownershipType,
contentRangeStart,
onBytesReceived,
}: DownloadFileParams): Promise<ClientDisplayableError | undefined> {
const url = this.getFilesDownloadUrl(ownershipType)
const pullChunkSize = file.encryptedChunkSizes[chunkIndex]
const response = await this.tokenRefreshableRequest<DownloadFileChunkResponse>({
const request: HttpRequest = {
verb: HttpVerb.Get,
url,
customHeaders: [
{ key: 'x-valet-token', value: apiToken },
{ key: 'x-valet-token', value: valetToken },
{
key: 'x-chunk-size',
value: pullChunkSize.toString(),
},
{ key: 'range', value: `bytes=${contentRangeStart}-` },
],
fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk,
responseType: 'arraybuffer',
}
const response = await this.tokenRefreshableRequest<DownloadFileChunkResponse>({
...request,
fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk,
})
if (isErrorResponse(response)) {
@@ -833,7 +886,14 @@ export class SNApiService
await onBytesReceived(bytesReceived)
if (rangeEnd < totalSize - 1) {
return this.downloadFile(file, ++chunkIndex, apiToken, rangeStart + pullChunkSize, onBytesReceived)
return this.downloadFile({
file,
chunkIndex: ++chunkIndex,
valetToken,
ownershipType,
contentRangeStart: rangeStart + pullChunkSize,
onBytesReceived,
})
}
return undefined
@@ -889,19 +949,4 @@ export class SNApiService
return this.session.accessToken
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
api: {
hasSession: this.session != undefined,
user: this.user,
registering: this.registering,
authenticating: this.authenticating,
changing: this.changing,
refreshingSession: this.refreshingSession,
filesHost: this.filesHost,
host: this.host,
},
})
}
}

View File

@@ -1,12 +1,22 @@
const FilesPaths = {
closeUploadSession: '/v1/files/upload/close-session',
createFileValetToken: '/v1/files/valet-tokens',
createUserFileValetToken: '/v1/files/valet-tokens',
deleteFile: '/v1/files',
downloadFileChunk: '/v1/files',
downloadVaultFileChunk: '/v1/vaults/files',
startUploadSession: '/v1/files/upload/create-session',
uploadFileChunk: '/v1/files/upload/chunk',
}
const SharedVaultFilesPaths = {
closeSharedVaultUploadSession: '/v1/shared-vault/files/upload/close-session',
deleteSharedVaultFile: '/v1/shared-vault/files',
downloadSharedVaultFileChunk: '/v1/shared-vault/files',
startSharedVaultUploadSession: '/v1/shared-vault/files/upload/create-session',
uploadSharedVaultFileChunk: '/v1/shared-vault/files/upload/chunk',
moveFile: '/v1/shared-vault/files/move',
}
const UserPaths = {
changeCredentials: (userUuid: string) => `/v1/users/${userUuid}/attributes/credentials`,
deleteAccount: (userUuid: string) => `/v1/users/${userUuid}`,
@@ -58,6 +68,7 @@ const ListedPaths = {
export const Paths = {
v1: {
...FilesPaths,
...SharedVaultFilesPaths,
...ItemsPaths,
...ListedPaths,
...SettingsPaths,

View File

@@ -1,7 +1,6 @@
import { Challenge, ChallengeValue, ChallengeArtifacts } from '@standardnotes/services'
import { Challenge, ChallengeValue, ChallengeArtifacts, ChallengeValueCallback } from '@standardnotes/services'
import { ChallengeResponse } from './ChallengeResponse'
import { removeFromArray } from '@standardnotes/utils'
import { ValueCallback } from './ChallengeService'
/**
* A challenge operation stores user-submitted values and callbacks.
@@ -15,8 +14,8 @@ export class ChallengeOperation {
constructor(
public challenge: Challenge,
public onValidValue: ValueCallback,
public onInvalidValue: ValueCallback,
public onValidValue: ChallengeValueCallback,
public onInvalidValue: ChallengeValueCallback,
public onNonvalidatedSubmit: (response: ChallengeResponse) => void,
public onComplete: (response: ChallengeResponse) => void,
public onCancel: () => void,

View File

@@ -16,6 +16,7 @@ import {
ChallengePrompt,
EncryptionService,
ChallengeStrings,
ChallengeObserver,
} from '@standardnotes/services'
import { ChallengeResponse } from './ChallengeResponse'
import { ChallengeOperation } from './ChallengeOperation'
@@ -25,16 +26,6 @@ type ChallengeValidationResponse = {
artifacts?: ChallengeArtifacts
}
export type ValueCallback = (value: ChallengeValue) => void
export type ChallengeObserver = {
onValidValue?: ValueCallback
onInvalidValue?: ValueCallback
onNonvalidatedSubmit?: (response: ChallengeResponse) => void
onComplete?: (response: ChallengeResponse) => void
onCancel?: () => void
}
const clearChallengeObserver = (observer: ChallengeObserver) => {
observer.onCancel = undefined
observer.onComplete = undefined
@@ -112,7 +103,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
return value.value as string
}
async promptForAccountPassword(): Promise<boolean> {
async promptForAccountPassword(): Promise<string | null> {
if (!this.protocolService.hasAccount()) {
throw Error('Requiring account password for challenge with no account')
}
@@ -126,11 +117,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
),
)
if (response) {
return true
} else {
return false
}
return response?.getValueForType(ChallengeValidation.AccountPassword)?.value as string
}
/**
@@ -175,7 +162,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
return this.protocolService.isPasscodeLocked()
}
public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver) {
public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver): () => void {
const observers = this.challengeObservers[challenge.id] || []
observers.push(observer)
@@ -303,11 +290,11 @@ export class ChallengeService extends AbstractService implements ChallengeServic
}
public setValidationStatusForChallenge(
challenge: Challenge,
challenge: ChallengeInterface,
value: ChallengeValue,
valid: boolean,
artifacts?: ChallengeArtifacts,
) {
): void {
const operation = this.getChallengeOperation(challenge)
operation.setValueStatus(value, valid, artifacts)

View File

@@ -19,6 +19,7 @@ import {
InternalEventBusInterface,
AlertService,
DeviceInterface,
MutatorClientInterface,
} from '@standardnotes/services'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
@@ -27,6 +28,7 @@ import { SNSyncService } from '../Sync/SyncService'
describe('featuresService', () => {
let itemManager: ItemManager
let mutator: MutatorClientInterface
let featureService: SNFeaturesService
let alertService: AlertService
let syncService: SNSyncService
@@ -52,6 +54,7 @@ describe('featuresService', () => {
const manager = new SNComponentManager(
itemManager,
mutator,
syncService,
featureService,
prefsService,
@@ -71,12 +74,14 @@ describe('featuresService', () => {
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue([])
itemManager.createItem = jest.fn()
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<GenericItem>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<GenericItem>)
mutator.setItemsToBeDeleted = jest.fn()
mutator.changeItem = jest.fn()
mutator.changeFeatureRepo = jest.fn()
featureService = {} as jest.Mocked<SNFeaturesService>

View File

@@ -39,6 +39,7 @@ import {
AlertService,
DeviceInterface,
isMobileDevice,
MutatorClientInterface,
} from '@standardnotes/services'
const DESKTOP_URL_PREFIX = 'sn://'
@@ -78,6 +79,7 @@ export class SNComponentManager
constructor(
private itemManager: ItemManager,
private mutator: MutatorClientInterface,
private syncService: SNSyncService,
private featuresService: SNFeaturesService,
private preferencesSerivce: SNPreferencesService,
@@ -162,6 +164,7 @@ export class SNComponentManager
const viewer = new ComponentViewer(
component,
this.itemManager,
this.mutator,
this.syncService,
this.alertService,
this.preferencesSerivce,
@@ -482,7 +485,7 @@ export class SNComponentManager
}
}
await this.itemManager.changeItem(component, (m) => {
await this.mutator.changeItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.permissions = componentPermissions
})
@@ -546,14 +549,14 @@ export class SNComponentManager
const theme = this.findComponent(uuid) as SNTheme
if (theme.active) {
await this.itemManager.changeComponent(theme, (mutator) => {
await this.mutator.changeComponent(theme, (mutator) => {
mutator.active = false
})
} else {
const activeThemes = this.getActiveThemes()
/* Activate current before deactivating others, so as not to flicker */
await this.itemManager.changeComponent(theme, (mutator) => {
await this.mutator.changeComponent(theme, (mutator) => {
mutator.active = true
})
@@ -562,13 +565,15 @@ export class SNComponentManager
await sleep(10)
for (const candidate of activeThemes) {
if (candidate && !candidate.isLayerable()) {
await this.itemManager.changeComponent(candidate, (mutator) => {
await this.mutator.changeComponent(candidate, (mutator) => {
mutator.active = false
})
}
}
}
}
void this.syncService.sync()
}
async toggleComponent(uuid: UuidString): Promise<void> {
@@ -580,9 +585,11 @@ export class SNComponentManager
return
}
await this.itemManager.changeComponent(component, (mutator) => {
await this.mutator.changeComponent(component, (mutator) => {
mutator.active = !(mutator.getItem() as SNComponent).active
})
void this.syncService.sync()
}
isComponentActive(component: SNComponent): boolean {

View File

@@ -5,6 +5,7 @@ import {
FeatureStatus,
FeaturesEvent,
AlertService,
MutatorClientInterface,
} from '@standardnotes/services'
import { SNFeaturesService } from '@Lib/Services'
import {
@@ -109,6 +110,7 @@ export class ComponentViewer implements ComponentViewerInterface {
constructor(
public readonly component: SNComponent,
private itemManager: ItemManager,
private mutator: MutatorClientInterface,
private syncService: SNSyncService,
private alertService: AlertService,
private preferencesSerivce: SNPreferencesService,
@@ -719,7 +721,7 @@ export class ComponentViewer implements ComponentViewerInterface {
...contextualPayload,
})
const template = CreateDecryptedItemFromPayload(payload)
await this.itemManager.insertItem(template)
await this.mutator.insertItem(template)
} else {
if (contextualPayload.content_type !== item.content_type) {
throw Error('Extension is trying to modify content type of item.')
@@ -727,7 +729,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}
}
await this.itemManager.changeItems(
await this.mutator.changeItems(
items.filter(isNotUndefined),
(mutator) => {
const contextualPayload = sureSearchArray(contextualPayloads, {
@@ -798,9 +800,9 @@ export class ComponentViewer implements ComponentViewerInterface {
})
const template = CreateDecryptedItemFromPayload(payload)
const item = await this.itemManager.insertItem(template)
const item = await this.mutator.insertItem(template)
await this.itemManager.changeItem(
await this.mutator.changeItem(
item,
(mutator) => {
if (responseItem.clientData) {
@@ -857,7 +859,7 @@ export class ComponentViewer implements ComponentViewerInterface {
void this.alertService.alert('The item you are trying to delete cannot be found.')
continue
}
await this.itemManager.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
await this.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
}
void this.syncService.sync()
@@ -875,7 +877,7 @@ export class ComponentViewer implements ComponentViewerInterface {
handleSetComponentDataMessage(message: ComponentMessage): void {
const noPermissionsRequired: ComponentPermission[] = []
this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => {
await this.itemManager.changeComponent(this.component, (mutator) => {
await this.mutator.changeComponent(this.component, (mutator) => {
mutator.componentData = message.data.componentData || {}
})

View File

@@ -14,6 +14,7 @@ import {
FeaturesEvent,
FeatureStatus,
InternalEventBusInterface,
MutatorClientInterface,
StorageKey,
UserService,
} from '@standardnotes/services'
@@ -25,6 +26,7 @@ describe('featuresService', () => {
let storageService: DiskStorageService
let apiService: SNApiService
let itemManager: ItemManager
let mutator: MutatorClientInterface
let webSocketsService: SNWebSocketsService
let settingsService: SNSettingsService
let userService: UserService
@@ -46,6 +48,7 @@ describe('featuresService', () => {
storageService,
apiService,
itemManager,
mutator,
webSocketsService,
settingsService,
userService,
@@ -95,13 +98,15 @@ describe('featuresService', () => {
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue(items)
itemManager.createItem = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
mutator.setItemsToBeDeleted = jest.fn()
mutator.changeItem = jest.fn()
mutator.changeFeatureRepo = jest.fn()
webSocketsService = {} as jest.Mocked<SNWebSocketsService>
webSocketsService.addEventObserver = jest.fn()
@@ -173,7 +178,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
expect(mutator.createItem).not.toHaveBeenCalled()
})
it('does create a component for enabled experimental feature', async () => {
@@ -196,7 +201,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).toHaveBeenCalled()
expect(mutator.createItem).toHaveBeenCalled()
})
})
@@ -300,8 +305,8 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).toHaveBeenCalledTimes(2)
expect(itemManager.createItem).toHaveBeenCalledWith(
expect(mutator.createItem).toHaveBeenCalledTimes(2)
expect(mutator.createItem).toHaveBeenCalledWith(
ContentType.Theme,
expect.objectContaining({
package_info: expect.objectContaining({
@@ -312,7 +317,7 @@ describe('featuresService', () => {
}),
true,
)
expect(itemManager.createItem).toHaveBeenCalledWith(
expect(mutator.createItem).toHaveBeenCalledWith(
ContentType.Component,
expect.objectContaining({
package_info: expect.objectContaining({
@@ -346,7 +351,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function))
expect(mutator.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function))
})
it('creates items for expired components if they do not exist', async () => {
@@ -373,7 +378,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).toHaveBeenCalledWith(
expect(mutator.createItem).toHaveBeenCalledWith(
ContentType.Component,
expect.objectContaining({
package_info: expect.objectContaining({
@@ -403,7 +408,7 @@ describe('featuresService', () => {
const now = new Date()
const yesterday = now.setDate(now.getDate() - 1)
itemManager.changeComponent = jest.fn().mockReturnValue(existingItem)
mutator.changeComponent = jest.fn().mockReturnValue(existingItem)
storageService.getValue = jest.fn().mockReturnValue(roles)
itemManager.getItems = jest.fn().mockReturnValue([existingItem])
apiService.getUserFeatures = jest.fn().mockReturnValue({
@@ -422,7 +427,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem])
expect(mutator.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem])
})
it('does not create an item for a feature without content type', async () => {
@@ -447,7 +452,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
expect(mutator.createItem).not.toHaveBeenCalled()
})
it('does not create an item for deprecated features', async () => {
@@ -472,7 +477,7 @@ describe('featuresService', () => {
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
await featuresService.fetchFeatures('123', didChangeRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
expect(mutator.createItem).not.toHaveBeenCalled()
})
it('does nothing after initial update if roles have not changed', async () => {

View File

@@ -48,6 +48,7 @@ import {
SetOfflineFeaturesFunctionResponse,
StorageKey,
UserService,
MutatorClientInterface,
} from '@standardnotes/services'
import { FeatureIdentifier } from '@standardnotes/features'
@@ -72,7 +73,8 @@ export class SNFeaturesService
private storageService: DiskStorageService,
private apiService: SNApiService,
private itemManager: ItemManager,
private webSocketsService: SNWebSocketsService,
private mutator: MutatorClientInterface,
webSocketsService: SNWebSocketsService,
private settingsService: SNSettingsService,
private userService: UserService,
private syncService: SNSyncService,
@@ -188,7 +190,7 @@ export class SNFeaturesService
if (existingItem) {
const hasChange = JSON.stringify(feature) !== JSON.stringify(existingItem.package_info)
if (hasChange) {
await this.itemManager.changeComponent(existingItem, (mutator) => {
await this.mutator.changeComponent(existingItem, (mutator) => {
mutator.package_info = feature
})
}
@@ -196,7 +198,7 @@ export class SNFeaturesService
continue
}
await this.itemManager.createItem(
await this.mutator.createItem(
feature.content_type,
this.componentContentForNativeFeatureDescription(feature),
true,
@@ -230,7 +232,7 @@ export class SNFeaturesService
return
}
void this.itemManager.setItemToBeDeleted(component).then(() => {
void this.mutator.setItemToBeDeleted(component).then(() => {
void this.syncService.sync()
})
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
@@ -270,7 +272,7 @@ export class SNFeaturesService
return result
}
const offlineRepo = (await this.itemManager.createItem(
const offlineRepo = (await this.mutator.createItem(
ContentType.ExtensionRepo,
FillItemContent({
offlineFeaturesUrl: result.featuresUrl,
@@ -298,7 +300,7 @@ export class SNFeaturesService
public async deleteOfflineFeatureRepo(): Promise<void> {
const repo = this.getOfflineRepo()
if (repo) {
await this.itemManager.setItemToBeDeleted(repo)
await this.mutator.setItemToBeDeleted(repo)
void this.syncService.sync()
}
await this.storageService.removeValue(StorageKey.UserFeatures)
@@ -346,7 +348,7 @@ export class SNFeaturesService
userKey,
true,
)
await this.itemManager.changeFeatureRepo(item, (m) => {
await this.mutator.changeFeatureRepo(item, (m) => {
m.migratedToUserSetting = true
})
}
@@ -371,7 +373,7 @@ export class SNFeaturesService
const userKeyMatch = repoUrl.match(/\w{32,64}/)
if (userKeyMatch && userKeyMatch.length > 0) {
const userKey = userKeyMatch[0]
const updatedRepo = await this.itemManager.changeFeatureRepo(item, (m) => {
const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => {
m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL
m.offlineKey = userKey
m.migratedToOfflineEntitlements = true
@@ -647,7 +649,7 @@ export class SNFeaturesService
}
}
await this.itemManager.setItemsToBeDeleted(itemsToDelete)
await this.mutator.setItemsToBeDeleted(itemsToDelete)
if (hasChanges) {
void this.syncService.sync()
@@ -704,7 +706,7 @@ export class SNFeaturesService
const hasChange = hasChangeInPackageInfo || hasChangeInExpiration
if (hasChange) {
resultingItem = await this.itemManager.changeComponent(existingItem, (mutator) => {
resultingItem = await this.mutator.changeComponent(existingItem, (mutator) => {
mutator.package_info = feature
mutator.valid_until = featureExpiresAt
})
@@ -714,7 +716,7 @@ export class SNFeaturesService
resultingItem = existingItem
}
} else if (!expired || feature.content_type === ContentType.Component) {
resultingItem = (await this.itemManager.createItem(
resultingItem = (await this.mutator.createItem(
feature.content_type,
this.componentContentForNativeFeatureDescription(feature),
true,
@@ -835,7 +837,7 @@ export class SNFeaturesService
;(this.storageService as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.webSocketsService as unknown) = undefined
;(this.mutator as unknown) = undefined
;(this.settingsService as unknown) = undefined
;(this.userService as unknown) = undefined
;(this.syncService as unknown) = undefined

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/common'
import { InternalEventBusInterface, ItemRelationshipDirection } from '@standardnotes/services'
import { AlertService, InternalEventBusInterface, ItemRelationshipDirection } from '@standardnotes/services'
import { ItemManager } from './ItemManager'
import { PayloadManager } from '../Payloads/PayloadManager'
import { UuidGenerator } from '@standardnotes/utils'
import { UuidGenerator, assert } from '@standardnotes/utils'
import * as Models from '@standardnotes/models'
import {
DecryptedPayload,
@@ -15,6 +15,7 @@ import {
SystemViewId,
} from '@standardnotes/models'
import { createNoteWithTitle } from '../../Spec/SpecUtils'
import { MutatorService } from '../Mutator'
const setupRandomUuid = () => {
UuidGenerator.SetGenerator(() => String(Math.random()))
@@ -43,15 +44,11 @@ const LongTextPredicate = Models.predicateFromJson<Models.SNTag>({
})
describe('itemManager', () => {
let mutator: MutatorService
let payloadManager: PayloadManager
let itemManager: ItemManager
let items: Models.DecryptedItemInterface[]
let internalEventBus: InternalEventBusInterface
const createService = () => {
return new ItemManager(payloadManager, internalEventBus)
}
beforeEach(() => {
setupRandomUuid()
@@ -59,16 +56,9 @@ describe('itemManager', () => {
internalEventBus.publish = jest.fn()
payloadManager = new PayloadManager(internalEventBus)
itemManager = new ItemManager(payloadManager, internalEventBus)
items = [] as jest.Mocked<Models.DecryptedItemInterface[]>
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue(items)
itemManager.createItem = jest.fn()
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<Models.DecryptedItemInterface>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
mutator = new MutatorService(itemManager, payloadManager, {} as jest.Mocked<AlertService>, internalEventBus)
})
const createTag = (title: string) => {
@@ -99,8 +89,6 @@ describe('itemManager', () => {
describe('item emit', () => {
it('deleted payloads should map to removed items', async () => {
itemManager = createService()
const payload = new DeletedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
@@ -120,8 +108,6 @@ describe('itemManager', () => {
})
it('decrypted items who become encrypted should be removed from ui', async () => {
itemManager = createService()
const decrypted = new DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
@@ -154,11 +140,10 @@ describe('itemManager', () => {
describe('note display criteria', () => {
it('viewing notes with tag', async () => {
itemManager = createService()
const tag = createTag('parent')
const note = createNoteWithTitle('note')
await itemManager.insertItems([tag, note])
await itemManager.addTagToNote(note, tag, false)
await mutator.insertItems([tag, note])
await mutator.addTagToNote(note, tag, false)
itemManager.setPrimaryItemDisplayOptions({
tags: [tag],
@@ -171,21 +156,19 @@ describe('itemManager', () => {
})
it('viewing trashed notes smart view should include archived notes', async () => {
itemManager = createService()
const archivedNote = createNoteWithTitle('archived')
const trashedNote = createNoteWithTitle('trashed')
const archivedAndTrashedNote = createNoteWithTitle('archived&trashed')
await itemManager.insertItems([archivedNote, trashedNote, archivedAndTrashedNote])
await mutator.insertItems([archivedNote, trashedNote, archivedAndTrashedNote])
await itemManager.changeItem<Models.NoteMutator>(archivedNote, (m) => {
await mutator.changeItem<Models.NoteMutator>(archivedNote, (m) => {
m.archived = true
})
await itemManager.changeItem<Models.NoteMutator>(trashedNote, (m) => {
await mutator.changeItem<Models.NoteMutator>(trashedNote, (m) => {
m.trashed = true
})
await itemManager.changeItem<Models.NoteMutator>(archivedAndTrashedNote, (m) => {
await mutator.changeItem<Models.NoteMutator>(archivedAndTrashedNote, (m) => {
m.trashed = true
m.archived = true
})
@@ -206,58 +189,53 @@ describe('itemManager', () => {
describe('tag relationships', () => {
it('updates parentId of child tag', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
await mutator.insertItems([parent, child])
await mutator.setTagParent(parent, child)
const changedChild = itemManager.findItem(child.uuid) as Models.SNTag
expect(changedChild.parentId).toBe(parent.uuid)
})
it('forbids a tag to be its own parent', async () => {
itemManager = createService()
const tag = createTag('tag')
await itemManager.insertItems([tag])
await mutator.insertItems([tag])
expect(() => itemManager.setTagParent(tag, tag)).toThrow()
await expect(mutator.setTagParent(tag, tag)).rejects.toThrow()
expect(itemManager.getTagParent(tag)).toBeUndefined()
})
it('forbids a tag to be its own ancestor', async () => {
itemManager = createService()
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([child, parent, grandParent])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
await mutator.insertItems([child, parent, grandParent])
await mutator.setTagParent(parent, child)
await mutator.setTagParent(grandParent, parent)
expect(() => itemManager.setTagParent(child, grandParent)).toThrow()
await expect(mutator.setTagParent(child, grandParent)).rejects.toThrow()
expect(itemManager.getTagParent(grandParent)).toBeUndefined()
})
it('getTagParent', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
await mutator.insertItems([parent, child])
await mutator.setTagParent(parent, child)
expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid)
})
it('findTagByTitleAndParent', async () => {
itemManager = createService()
const parent = createTag('name1')
const child = createTag('childName')
const duplicateNameChild = createTag('name1')
await itemManager.insertItems([parent, child, duplicateNameChild])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(parent, duplicateNameChild)
await mutator.insertItems([parent, child, duplicateNameChild])
await mutator.setTagParent(parent, child)
await mutator.setTagParent(parent, duplicateNameChild)
const a = itemManager.findTagByTitleAndParent('name1', undefined)
const b = itemManager.findTagByTitleAndParent('name1', parent)
@@ -270,16 +248,16 @@ describe('itemManager', () => {
it('findOrCreateTagByTitle', async () => {
setupRandomUuid()
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
await mutator.insertItems([parent, child])
await mutator.setTagParent(parent, child)
const childA = await itemManager.findOrCreateTagByTitle('child')
const childB = await itemManager.findOrCreateTagByTitle('child', parent)
const childC = await itemManager.findOrCreateTagByTitle('child-bis', parent)
const childD = await itemManager.findOrCreateTagByTitle('child-bis', parent)
const childA = await mutator.findOrCreateTagByTitle({ title: 'child' })
const childB = await mutator.findOrCreateTagByTitle({ title: 'child', parentItemToLookupUuidFor: parent })
const childC = await mutator.findOrCreateTagByTitle({ title: 'child-bis', parentItemToLookupUuidFor: parent })
const childD = await mutator.findOrCreateTagByTitle({ title: 'child-bis', parentItemToLookupUuidFor: parent })
expect(childA.uuid).not.toEqual(child.uuid)
expect(childB.uuid).toEqual(child.uuid)
@@ -292,17 +270,16 @@ describe('itemManager', () => {
})
it('findOrCreateTagParentChain', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
await mutator.insertItems([parent, child])
await mutator.setTagParent(parent, child)
const a = await itemManager.findOrCreateTagParentChain(['parent'])
const b = await itemManager.findOrCreateTagParentChain(['parent', 'child'])
const c = await itemManager.findOrCreateTagParentChain(['parent', 'child2'])
const d = await itemManager.findOrCreateTagParentChain(['parent2', 'child1'])
const a = await mutator.findOrCreateTagParentChain(['parent'])
const b = await mutator.findOrCreateTagParentChain(['parent', 'child'])
const c = await mutator.findOrCreateTagParentChain(['parent', 'child2'])
const d = await mutator.findOrCreateTagParentChain(['parent2', 'child1'])
expect(a?.uuid).toEqual(parent.uuid)
expect(b?.uuid).toEqual(child.uuid)
@@ -317,15 +294,14 @@ describe('itemManager', () => {
})
it('isAncestor', async () => {
itemManager = createService()
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
const another = createTag('another')
await itemManager.insertItems([child, parent, grandParent, another])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
await mutator.insertItems([child, parent, grandParent, another])
await mutator.setTagParent(parent, child)
await mutator.setTagParent(grandParent, parent)
expect(itemManager.isTagAncestor(grandParent, parent)).toEqual(true)
expect(itemManager.isTagAncestor(grandParent, child)).toEqual(true)
@@ -341,28 +317,26 @@ describe('itemManager', () => {
})
it('unsetTagRelationship', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
await mutator.insertItems([parent, child])
await mutator.setTagParent(parent, child)
expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid)
await itemManager.unsetTagParent(child)
await mutator.unsetTagParent(child)
expect(itemManager.getTagParent(child)).toBeUndefined()
})
it('getTagParentChain', async () => {
itemManager = createService()
const greatGrandParent = createTag('greatGrandParent')
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([greatGrandParent, grandParent, parent, child])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
await itemManager.setTagParent(greatGrandParent, grandParent)
await mutator.insertItems([greatGrandParent, grandParent, parent, child])
await mutator.setTagParent(parent, child)
await mutator.setTagParent(grandParent, parent)
await mutator.setTagParent(greatGrandParent, grandParent)
const uuidChain = itemManager.getTagParentChain(child).map((tag) => tag.uuid)
@@ -371,18 +345,17 @@ describe('itemManager', () => {
})
it('viewing notes for parent tag should not display notes of children', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
await itemManager.insertItems([parentTag, childTag])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag])
await mutator.setTagParent(parentTag, childTag)
const parentNote = createNoteWithTitle('parentNote')
const childNote = createNoteWithTitle('childNote')
await itemManager.insertItems([parentNote, childNote])
await mutator.insertItems([parentNote, childNote])
await itemManager.addTagToNote(parentNote, parentTag, false)
await itemManager.addTagToNote(childNote, childTag, false)
await mutator.addTagToNote(parentNote, parentTag, false)
await mutator.addTagToNote(childNote, childTag, false)
itemManager.setPrimaryItemDisplayOptions({
tags: [parentTag],
@@ -397,7 +370,6 @@ describe('itemManager', () => {
describe('template items', () => {
it('create template item', async () => {
itemManager = createService()
setupRandomUuid()
const item = await itemManager.createTemplateItem(ContentType.Note, {
@@ -412,7 +384,6 @@ describe('itemManager', () => {
})
it('isTemplateItem return the correct value', async () => {
itemManager = createService()
setupRandomUuid()
const item = await itemManager.createTemplateItem(ContentType.Note, {
@@ -422,13 +393,12 @@ describe('itemManager', () => {
expect(itemManager.isTemplateItem(item)).toEqual(true)
await itemManager.insertItem(item)
await mutator.insertItem(item)
expect(itemManager.isTemplateItem(item)).toEqual(false)
})
it('isTemplateItem return the correct value for system smart views', () => {
itemManager = createService()
setupRandomUuid()
const [systemTag1, ...restOfSystemViews] = itemManager
@@ -445,29 +415,27 @@ describe('itemManager', () => {
describe('tags', () => {
it('lets me create a regular tag with a clear API', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createTag('this is my new tag')
const tag = await mutator.createTag({ title: 'this is my new tag' })
expect(tag).toBeTruthy()
expect(itemManager.isTemplateItem(tag)).toEqual(false)
})
it('should search tags correctly', async () => {
itemManager = createService()
setupRandomUuid()
const foo = await itemManager.createTag('foo[')
const foobar = await itemManager.createTag('foo[bar]')
const bar = await itemManager.createTag('bar[')
const barfoo = await itemManager.createTag('bar[foo]')
const fooDelimiter = await itemManager.createTag('bar.foo')
const barFooDelimiter = await itemManager.createTag('baz.bar.foo')
const fooAttached = await itemManager.createTag('Foo')
const foo = await mutator.createTag({ title: 'foo[' })
const foobar = await mutator.createTag({ title: 'foo[bar]' })
const bar = await mutator.createTag({ title: 'bar[' })
const barfoo = await mutator.createTag({ title: 'bar[foo]' })
const fooDelimiter = await mutator.createTag({ title: 'bar.foo' })
const barFooDelimiter = await mutator.createTag({ title: 'baz.bar.foo' })
const fooAttached = await mutator.createTag({ title: 'Foo' })
const note = createNoteWithTitle('note')
await itemManager.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note])
await itemManager.addTagToNote(note, fooAttached, false)
await mutator.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note])
await mutator.addTagToNote(note, fooAttached, false)
const fooResults = itemManager.searchTags('foo')
expect(fooResults).toContainEqual(foo)
@@ -482,19 +450,17 @@ describe('itemManager', () => {
describe('tags notes index', () => {
it('counts countable notes', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
await itemManager.insertItems([parentTag, childTag])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag])
await mutator.setTagParent(parentTag, childTag)
const parentNote = createNoteWithTitle('parentNote')
const childNote = createNoteWithTitle('childNote')
await itemManager.insertItems([parentNote, childNote])
await mutator.insertItems([parentNote, childNote])
await itemManager.addTagToNote(parentNote, parentTag, false)
await itemManager.addTagToNote(childNote, childTag, false)
await mutator.addTagToNote(parentNote, parentTag, false)
await mutator.addTagToNote(childNote, childTag, false)
expect(itemManager.countableNotesForTag(parentTag)).toBe(1)
expect(itemManager.countableNotesForTag(childTag)).toBe(1)
@@ -502,29 +468,27 @@ describe('itemManager', () => {
})
it('archiving a note should update count index', async () => {
itemManager = createService()
const tag1 = createTag('tag 1')
await itemManager.insertItems([tag1])
await mutator.insertItems([tag1])
const note1 = createNoteWithTitle('note 1')
const note2 = createNoteWithTitle('note 2')
await itemManager.insertItems([note1, note2])
await mutator.insertItems([note1, note2])
await itemManager.addTagToNote(note1, tag1, false)
await itemManager.addTagToNote(note2, tag1, false)
await mutator.addTagToNote(note1, tag1, false)
await mutator.addTagToNote(note2, tag1, false)
expect(itemManager.countableNotesForTag(tag1)).toBe(2)
expect(itemManager.allCountableNotesCount()).toBe(2)
await itemManager.changeItem<Models.NoteMutator>(note1, (m) => {
await mutator.changeItem<Models.NoteMutator>(note1, (m) => {
m.archived = true
})
expect(itemManager.allCountableNotesCount()).toBe(1)
expect(itemManager.countableNotesForTag(tag1)).toBe(1)
await itemManager.changeItem<Models.NoteMutator>(note1, (m) => {
await mutator.changeItem<Models.NoteMutator>(note1, (m) => {
m.archived = false
})
@@ -535,13 +499,12 @@ describe('itemManager', () => {
describe('smart views', () => {
it('lets me create a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const [view1, view2, view3] = await Promise.all([
itemManager.createSmartView('Not Pinned', NotPinnedPredicate),
itemManager.createSmartView('Last Day', LastDayPredicate),
itemManager.createSmartView('Long', LongTextPredicate),
mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate }),
mutator.createSmartView({ title: 'Last Day', predicate: LastDayPredicate }),
mutator.createSmartView({ title: 'Long', predicate: LongTextPredicate }),
])
expect(view1).toBeTruthy()
@@ -554,10 +517,9 @@ describe('itemManager', () => {
})
it('lets me use a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const view = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
const view = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate })
const notes = itemManager.notesMatchingSmartView(view)
@@ -565,7 +527,6 @@ describe('itemManager', () => {
})
it('lets me test if a title is a smart view', () => {
itemManager = createService()
setupRandomUuid()
expect(itemManager.isSmartViewTitle(VIEW_NOT_PINNED)).toEqual(true)
@@ -577,13 +538,12 @@ describe('itemManager', () => {
})
it('lets me create a smart view from the DSL', async () => {
itemManager = createService()
setupRandomUuid()
const [tag1, tag2, tag3] = await Promise.all([
itemManager.createSmartViewFromDSL(VIEW_NOT_PINNED),
itemManager.createSmartViewFromDSL(VIEW_LAST_DAY),
itemManager.createSmartViewFromDSL(VIEW_LONG),
mutator.createSmartViewFromDSL(VIEW_NOT_PINNED),
mutator.createSmartViewFromDSL(VIEW_LAST_DAY),
mutator.createSmartViewFromDSL(VIEW_LONG),
])
expect(tag1).toBeTruthy()
@@ -596,11 +556,10 @@ describe('itemManager', () => {
})
it('will create smart view or tags from the generic method', async () => {
itemManager = createService()
setupRandomUuid()
const someTag = await itemManager.createTagOrSmartView('some-tag')
const someView = await itemManager.createTagOrSmartView(VIEW_LONG)
const someTag = await mutator.createTagOrSmartView('some-tag')
const someView = await mutator.createTagOrSmartView(VIEW_LONG)
expect(someTag.content_type).toEqual(ContentType.Tag)
expect(someView.content_type).toEqual(ContentType.SmartView)
@@ -608,12 +567,11 @@ describe('itemManager', () => {
})
it('lets me rename a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
const tag = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate })
await itemManager.changeItem<Models.TagMutator>(tag, (m) => {
await mutator.changeItem<Models.TagMutator>(tag, (m) => {
m.title = 'New Title'
})
@@ -625,10 +583,9 @@ describe('itemManager', () => {
})
it('lets me find a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
const tag = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate })
const view = itemManager.findItem(tag.uuid) as Models.SmartView
@@ -636,7 +593,6 @@ describe('itemManager', () => {
})
it('untagged notes smart view', async () => {
itemManager = createService()
setupRandomUuid()
const view = itemManager.untaggedNotesSmartView
@@ -644,11 +600,11 @@ describe('itemManager', () => {
const tag = createTag('tag')
const untaggedNote = createNoteWithTitle('note')
const taggedNote = createNoteWithTitle('taggedNote')
await itemManager.insertItems([tag, untaggedNote, taggedNote])
await mutator.insertItems([tag, untaggedNote, taggedNote])
expect(itemManager.notesMatchingSmartView(view)).toHaveLength(2)
await itemManager.addTagToNote(taggedNote, tag, false)
await mutator.addTagToNote(taggedNote, tag, false)
expect(itemManager.notesMatchingSmartView(view)).toHaveLength(1)
@@ -657,31 +613,28 @@ describe('itemManager', () => {
describe('files', () => {
it('should correctly rename file to filename that has extension', async () => {
itemManager = createService()
const file = createFile('initialName.ext')
await itemManager.insertItems([file])
await mutator.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt')
const renamedFile = await mutator.renameFile(file, 'anotherName.anotherExt')
expect(renamedFile.name).toBe('anotherName.anotherExt')
})
it('should correctly rename extensionless file to filename that has extension', async () => {
itemManager = createService()
const file = createFile('initialName')
await itemManager.insertItems([file])
await mutator.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt')
const renamedFile = await mutator.renameFile(file, 'anotherName.anotherExt')
expect(renamedFile.name).toBe('anotherName.anotherExt')
})
it('should correctly rename file to filename that does not have extension', async () => {
itemManager = createService()
const file = createFile('initialName.ext')
await itemManager.insertItems([file])
await mutator.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName')
const renamedFile = await mutator.renameFile(file, 'anotherName')
expect(renamedFile.name).toBe('anotherName')
})
@@ -689,15 +642,14 @@ describe('itemManager', () => {
describe('linking', () => {
it('adding a note to a tag hierarchy should add the note to its parent too', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const note = createNoteWithTitle('note')
await itemManager.insertItems([parentTag, childTag, note])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag, note])
await mutator.setTagParent(parentTag, childTag)
await itemManager.addTagToNote(note, childTag, true)
await mutator.addTagToNote(note, childTag, true)
const tags = itemManager.getSortedTagsForItem(note)
@@ -707,15 +659,14 @@ describe('itemManager', () => {
})
it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const note = createNoteWithTitle('note')
await itemManager.insertItems([parentTag, childTag, note])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag, note])
await mutator.setTagParent(parentTag, childTag)
await itemManager.addTagToNote(note, childTag, false)
await mutator.addTagToNote(note, childTag, false)
const tags = itemManager.getSortedTagsForItem(note)
@@ -724,15 +675,14 @@ describe('itemManager', () => {
})
it('adding a file to a tag hierarchy should add the file to its parent too', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const file = createFile('file')
await itemManager.insertItems([parentTag, childTag, file])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag, file])
await mutator.setTagParent(parentTag, childTag)
await itemManager.addTagToFile(file, childTag, true)
await mutator.addTagToFile(file, childTag, true)
const tags = itemManager.getSortedTagsForItem(file)
@@ -742,15 +692,14 @@ describe('itemManager', () => {
})
it('adding a file to a tag hierarchy should not add the file to its parent if hierarchy option is disabled', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const file = createFile('file')
await itemManager.insertItems([parentTag, childTag, file])
await itemManager.setTagParent(parentTag, childTag)
await mutator.insertItems([parentTag, childTag, file])
await mutator.setTagParent(parentTag, childTag)
await itemManager.addTagToFile(file, childTag, false)
await mutator.addTagToFile(file, childTag, false)
const tags = itemManager.getSortedTagsForItem(file)
@@ -759,12 +708,12 @@ describe('itemManager', () => {
})
it('should link file with note', async () => {
itemManager = createService()
const note = createNoteWithTitle('invoices')
const file = createFile('invoice_1.pdf')
await itemManager.insertItems([note, file])
await mutator.insertItems([note, file])
const resultingFile = await itemManager.associateFileWithNote(file, note)
const resultingFile = await mutator.associateFileWithNote(file, note)
assert(resultingFile)
const references = resultingFile.references
expect(references).toHaveLength(1)
@@ -772,25 +721,24 @@ describe('itemManager', () => {
})
it('should unlink file from note', async () => {
itemManager = createService()
const note = createNoteWithTitle('invoices')
const file = createFile('invoice_1.pdf')
await itemManager.insertItems([note, file])
await mutator.insertItems([note, file])
const associatedFile = await itemManager.associateFileWithNote(file, note)
const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note)
const associatedFile = await mutator.associateFileWithNote(file, note)
assert(associatedFile)
const disassociatedFile = await mutator.disassociateFileWithNote(associatedFile, note)
const references = disassociatedFile.references
expect(references).toHaveLength(0)
})
it('should link note to note', async () => {
itemManager = createService()
const note = createNoteWithTitle('research')
const note2 = createNoteWithTitle('citation')
await itemManager.insertItems([note, note2])
await mutator.insertItems([note, note2])
const resultingNote = await itemManager.linkNoteToNote(note, note2)
const resultingNote = await mutator.linkNoteToNote(note, note2)
const references = resultingNote.references
expect(references).toHaveLength(1)
@@ -798,12 +746,11 @@ describe('itemManager', () => {
})
it('should link file to file', async () => {
itemManager = createService()
const file = createFile('research')
const file2 = createFile('citation')
await itemManager.insertItems([file, file2])
await mutator.insertItems([file, file2])
const resultingfile = await itemManager.linkFileToFile(file, file2)
const resultingfile = await mutator.linkFileToFile(file, file2)
const references = resultingfile.references
expect(references).toHaveLength(1)
@@ -811,13 +758,12 @@ describe('itemManager', () => {
})
it('should get the relationship type for two items', async () => {
itemManager = createService()
const firstNote = createNoteWithTitle('First note')
const secondNote = createNoteWithTitle('Second note')
const unlinkedNote = createNoteWithTitle('Unlinked note')
await itemManager.insertItems([firstNote, secondNote, unlinkedNote])
await mutator.insertItems([firstNote, secondNote, unlinkedNote])
const firstNoteLinkedToSecond = await itemManager.linkNoteToNote(firstNote, secondNote)
const firstNoteLinkedToSecond = await mutator.linkNoteToNote(firstNote, secondNote)
const relationshipOfFirstNoteToSecond = itemManager.relationshipDirectionBetweenItems(
firstNoteLinkedToSecond,
@@ -838,13 +784,12 @@ describe('itemManager', () => {
})
it('should unlink itemOne from itemTwo if relation is direct', async () => {
itemManager = createService()
const note = createNoteWithTitle('Note 1')
const note2 = createNoteWithTitle('Note 2')
await itemManager.insertItems([note, note2])
await mutator.insertItems([note, note2])
const linkedItem = await itemManager.linkNoteToNote(note, note2)
const unlinkedItem = await itemManager.unlinkItems(linkedItem, note2)
const linkedItem = await mutator.linkNoteToNote(note, note2)
const unlinkedItem = await mutator.unlinkItems(linkedItem, note2)
const references = unlinkedItem.references
expect(unlinkedItem.uuid).toBe(note.uuid)
@@ -852,13 +797,12 @@ describe('itemManager', () => {
})
it('should unlink itemTwo from itemOne if relation is indirect', async () => {
itemManager = createService()
const note = createNoteWithTitle('Note 1')
const note2 = createNoteWithTitle('Note 2')
await itemManager.insertItems([note, note2])
await mutator.insertItems([note, note2])
const linkedItem = await itemManager.linkNoteToNote(note, note2)
const changedItem = await itemManager.unlinkItems(linkedItem, note2)
const linkedItem = await mutator.linkNoteToNote(note, note2)
const changedItem = await mutator.unlinkItems(linkedItem, note2)
expect(changedItem.uuid).toBe(note.uuid)
expect(changedItem.references).toHaveLength(0)

View File

@@ -1,14 +1,13 @@
import { ContentType } from '@standardnotes/common'
import { assert, naturalSort, removeFromArray, UuidGenerator, Uuids } from '@standardnotes/utils'
import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption'
import { SNItemsKey } from '@standardnotes/encryption'
import { PayloadManager } from '../Payloads/PayloadManager'
import { TagsToFoldersMigrationApplicator } from '../../Migrations/Applicators/TagsToFolders'
import { UuidString } from '../../Types/UuidString'
import * as Models from '@standardnotes/models'
import * as Services from '@standardnotes/services'
import { PayloadManagerChangeData } from '../Payloads'
import { DiagnosticInfo, ItemsClientInterface, ItemRelationshipDirection } from '@standardnotes/services'
import { CollectionSort, DecryptedItemInterface, ItemContent, SmartViewDefaultIconName } from '@standardnotes/models'
import { ItemRelationshipDirection } from '@standardnotes/services'
type ItemsChangeObserver<I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface> = {
contentType: ContentType[]
@@ -23,18 +22,18 @@ type ItemsChangeObserver<I extends Models.DecryptedItemInterface = Models.Decryp
* will then notify its observers (which is us), we'll convert the payloads to items,
* and then we'll propagate them to our listeners.
*/
export class ItemManager
extends Services.AbstractService
implements Services.ItemManagerInterface, ItemsClientInterface
{
export class ItemManager extends Services.AbstractService implements Services.ItemManagerInterface {
private unsubChangeObserver: () => void
private observers: ItemsChangeObserver[] = []
private collection!: Models.ItemCollection
private systemSmartViews: Models.SmartView[]
private tagItemsIndex!: Models.TagItemsIndex
private itemCounter!: Models.ItemCounter
private navigationDisplayController!: Models.ItemDisplayController<Models.SNNote | Models.FileItem>
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag>
private navigationDisplayController!: Models.ItemDisplayController<
Models.SNNote | Models.FileItem,
Models.NotesAndFilesDisplayOptions
>
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsDisplayOptions>
private itemsKeyDisplayController!: Models.ItemDisplayController<SNItemsKey>
private componentDisplayController!: Models.ItemDisplayController<Models.SNComponent>
private themeDisplayController!: Models.ItemDisplayController<Models.SNTheme>
@@ -52,11 +51,15 @@ export class ItemManager
this.unsubChangeObserver = this.payloadManager.addObserver(ContentType.Any, this.setPayloads.bind(this))
}
private rebuildSystemSmartViews(criteria: Models.FilterDisplayOptions): Models.SmartView[] {
private rebuildSystemSmartViews(criteria: Models.NotesAndFilesDisplayOptions): Models.SmartView[] {
this.systemSmartViews = Models.BuildSmartViews(criteria)
return this.systemSmartViews
}
public getCollection(): Models.ItemCollection {
return this.collection
}
private createCollection() {
this.collection = new Models.ItemCollection()
@@ -94,7 +97,7 @@ export class ItemManager
sortDirection: 'asc',
})
this.tagItemsIndex = new Models.TagItemsIndex(this.collection, this.tagItemsIndex?.observers)
this.itemCounter = new Models.ItemCounter(this.collection, this.itemCounter?.observers)
}
private get allDisplayControllers(): Models.ItemDisplayController<Models.DisplayItem>[] {
@@ -113,6 +116,10 @@ export class ItemManager
return this.collection.invalidElements()
}
public get invalidNonVaultedItems(): Models.EncryptedItemInterface[] {
return this.invalidItems.filter((item) => !item.key_system_identifier)
}
public createItemFromPayload(payload: Models.DecryptedPayloadInterface): Models.DecryptedItemInterface {
return Models.CreateDecryptedItemFromPayload(payload)
}
@@ -121,8 +128,8 @@ export class ItemManager
return new Models.DecryptedPayload(object)
}
public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void {
const override: Models.FilterDisplayOptions = {}
public setPrimaryItemDisplayOptions(options: Models.NotesAndFilesDisplayControllerOptions): void {
const override: Models.NotesAndFilesDisplayOptions = {}
const additionalFilters: Models.ItemFilter[] = []
if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) {
@@ -164,7 +171,7 @@ export class ItemManager
})
.filter((view) => view != undefined)
const updatedOptions: Models.DisplayOptions = {
const updatedOptions: Models.DisplayControllerDisplayOptions & Models.NotesAndFilesDisplayOptions = {
...options,
...override,
...{
@@ -173,7 +180,7 @@ export class ItemManager
},
}
if (updatedOptions.sortBy === CollectionSort.Title) {
if (updatedOptions.sortBy === Models.CollectionSort.Title) {
updatedOptions.sortDirection = updatedOptions.sortDirection === 'asc' ? 'dsc' : 'asc'
}
@@ -181,6 +188,17 @@ export class ItemManager
customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection, additionalFilters),
...updatedOptions,
})
this.itemCounter.setDisplayOptions(updatedOptions)
}
public setVaultDisplayOptions(options: Models.VaultDisplayOptions): void {
this.navigationDisplayController.setVaultDisplayOptions(options)
this.tagDisplayController.setVaultDisplayOptions(options)
this.smartViewDisplayController.setVaultDisplayOptions(options)
this.fileDisplayController.setVaultDisplayOptions(options)
this.itemCounter.setVaultDisplayOptions(options)
}
public getDisplayableNotes(): Models.SNNote[] {
@@ -214,7 +232,7 @@ export class ItemManager
;(this.unsubChangeObserver as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.collection as unknown) = undefined
;(this.tagItemsIndex as unknown) = undefined
;(this.itemCounter as unknown) = undefined
;(this.tagDisplayController as unknown) = undefined
;(this.navigationDisplayController as unknown) = undefined
;(this.itemsKeyDisplayController as unknown) = undefined
@@ -252,9 +270,6 @@ export class ItemManager
return this.findItem(uuid) as T
}
/**
* Returns all items matching given ids
*/
findItems<T extends Models.DecryptedItemInterface>(uuids: UuidString[]): T[] {
return this.collection.findAllDecrypted(uuids) as T[]
}
@@ -271,6 +286,7 @@ export class ItemManager
return this.collection.nondeletedElements().filter(Models.isDecryptedItem)
}
/** Unlock .items, this function includes error decrypting items */
allTrackedItems(): Models.ItemInterface[] {
return this.collection.all()
}
@@ -280,26 +296,26 @@ export class ItemManager
}
public addNoteCountChangeObserver(observer: Models.TagItemCountChangeObserver): () => void {
return this.tagItemsIndex.addCountChangeObserver(observer)
return this.itemCounter.addCountChangeObserver(observer)
}
public allCountableNotesCount(): number {
return this.tagItemsIndex.allCountableNotesCount()
return this.itemCounter.allCountableNotesCount()
}
public allCountableFilesCount(): number {
return this.tagItemsIndex.allCountableFilesCount()
return this.itemCounter.allCountableFilesCount()
}
public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number {
if (tag instanceof Models.SmartView) {
if (tag.uuid === Models.SystemViewId.AllNotes) {
return this.tagItemsIndex.allCountableNotesCount()
return this.itemCounter.allCountableNotesCount()
}
throw Error('countableItemsForTag is not meant to be used for smart views.')
}
return this.tagItemsIndex.countableItemsForTag(tag)
return this.itemCounter.countableItemsForTag(tag)
}
public getNoteCount(): number {
@@ -330,12 +346,12 @@ export class ItemManager
/**
* Returns the items that reference the given item, or an empty array if no results.
*/
public itemsReferencingItem(
itemToLookupUuidFor: Models.DecryptedItemInterface,
public itemsReferencingItem<I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface>(
itemToLookupUuidFor: { uuid: UuidString },
contentType?: ContentType,
): Models.DecryptedItemInterface[] {
): I[] {
const uuids = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid)
let referencing = this.findItems(uuids)
let referencing = this.findItems<I>(uuids)
if (contentType) {
referencing = referencing.filter((ref) => {
return ref?.content_type === contentType
@@ -405,7 +421,7 @@ export class ItemManager
}
this.collection.onChange(delta)
this.tagItemsIndex.onChange(delta)
this.itemCounter.onChange(delta)
const affectedContentTypesArray = Array.from(affectedContentTypes.values())
for (const controller of this.allDisplayControllers) {
@@ -509,250 +525,6 @@ export class ItemManager
}
}
/**
* Consumers wanting to modify an item should run it through this block,
* so that data is properly mapped through our function, and latest state
* is properly reconciled.
*/
public async changeItem<
M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator,
I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface,
>(
itemToLookupUuidFor: I,
mutate?: (mutator: M) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<I> {
const results = await this.changeItems<M, I>(
[itemToLookupUuidFor],
mutate,
mutationType,
emitSource,
payloadSourceKey,
)
return results[0]
}
/**
* @param mutate If not supplied, the intention would simply be to mark the item as dirty.
*/
public async changeItems<
M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator,
I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface,
>(
itemsToLookupUuidsFor: I[],
mutate?: (mutator: M) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<I[]> {
const items = this.findItemsIncludingBlanks(Uuids(itemsToLookupUuidsFor))
const payloads: Models.DecryptedPayloadInterface[] = []
for (const item of items) {
if (!item) {
throw Error('Attempting to change non-existant item')
}
const mutator = Models.CreateDecryptedMutatorForItem(item, mutationType)
if (mutate) {
mutate(mutator as M)
}
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey)
const results = this.findItems(payloads.map((p) => p.uuid)) as I[]
return results
}
/**
* Run unique mutations per each item in the array, then only propagate all changes
* once all mutations have been run. This differs from `changeItems` in that changeItems
* runs the same mutation on all items.
*/
public async runTransactionalMutations(
transactions: Models.TransactionalMutation[],
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<(Models.DecryptedItemInterface | undefined)[]> {
const payloads: Models.DecryptedPayloadInterface[] = []
for (const transaction of transactions) {
const item = this.findItem(transaction.itemUuid)
if (!item) {
continue
}
const mutator = Models.CreateDecryptedMutatorForItem(
item,
transaction.mutationType || Models.MutationType.UpdateUserTimestamps,
)
transaction.mutate(mutator)
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey)
const results = this.findItems(payloads.map((p) => p.uuid))
return results
}
public async runTransactionalMutation(
transaction: Models.TransactionalMutation,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.DecryptedItemInterface | undefined> {
const item = this.findSureItem(transaction.itemUuid)
const mutator = Models.CreateDecryptedMutatorForItem(
item,
transaction.mutationType || Models.MutationType.UpdateUserTimestamps,
)
transaction.mutate(mutator)
const payload = mutator.getResult()
await this.payloadManager.emitPayloads([payload], emitSource, payloadSourceKey)
const result = this.findItem(payload.uuid)
return result
}
async changeNote(
itemToLookupUuidFor: Models.SNNote,
mutate: (mutator: Models.NoteMutator) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.DecryptedPayloadInterface[]> {
const note = this.findItem<Models.SNNote>(itemToLookupUuidFor.uuid)
if (!note) {
throw Error('Attempting to change non-existant note')
}
const mutator = new Models.NoteMutator(note, mutationType)
return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
}
async changeTag(
itemToLookupUuidFor: Models.SNTag,
mutate: (mutator: Models.TagMutator) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.SNTag> {
const tag = this.findItem<Models.SNTag>(itemToLookupUuidFor.uuid)
if (!tag) {
throw Error('Attempting to change non-existant tag')
}
const mutator = new Models.TagMutator(tag, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.findSureItem<Models.SNTag>(itemToLookupUuidFor.uuid)
}
async changeComponent(
itemToLookupUuidFor: Models.SNComponent,
mutate: (mutator: Models.ComponentMutator) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.SNComponent> {
const component = this.findItem<Models.SNComponent>(itemToLookupUuidFor.uuid)
if (!component) {
throw Error('Attempting to change non-existant component')
}
const mutator = new Models.ComponentMutator(component, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.findSureItem<Models.SNComponent>(itemToLookupUuidFor.uuid)
}
async changeFeatureRepo(
itemToLookupUuidFor: Models.SNFeatureRepo,
mutate: (mutator: Models.FeatureRepoMutator) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.SNFeatureRepo> {
const repo = this.findItem(itemToLookupUuidFor.uuid)
if (!repo) {
throw Error('Attempting to change non-existant repo')
}
const mutator = new Models.FeatureRepoMutator(repo, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.findSureItem<Models.SNFeatureRepo>(itemToLookupUuidFor.uuid)
}
async changeActionsExtension(
itemToLookupUuidFor: Models.SNActionsExtension,
mutate: (mutator: Models.ActionsExtensionMutator) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.SNActionsExtension> {
const extension = this.findItem<Models.SNActionsExtension>(itemToLookupUuidFor.uuid)
if (!extension) {
throw Error('Attempting to change non-existant extension')
}
const mutator = new Models.ActionsExtensionMutator(extension, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.findSureItem<Models.SNActionsExtension>(itemToLookupUuidFor.uuid)
}
async changeItemsKey(
itemToLookupUuidFor: Models.ItemsKeyInterface,
mutate: (mutator: Models.ItemsKeyMutatorInterface) => void,
mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.ItemsKeyInterface> {
const itemsKey = this.findItem<SNItemsKey>(itemToLookupUuidFor.uuid)
if (!itemsKey) {
throw Error('Attempting to change non-existant itemsKey')
}
const mutator = new ItemsKeyMutator(itemsKey, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.findSureItem<Models.ItemsKeyInterface>(itemToLookupUuidFor.uuid)
}
private async applyTransform<T extends Models.DecryptedItemMutator>(
mutator: T,
mutate: (mutator: T) => void,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.DecryptedPayloadInterface[]> {
mutate(mutator)
const payload = mutator.getResult()
return this.payloadManager.emitPayload(payload, emitSource, payloadSourceKey)
}
/**
* Sets the item as needing sync. The item is then run through the mapping function,
* and propagated to mapping observers.
* @param isUserModified - Whether to update the item's "user modified date"
*/
public async setItemDirty(itemToLookupUuidFor: Models.DecryptedItemInterface, isUserModified = false) {
const result = await this.setItemsDirty([itemToLookupUuidFor], isUserModified)
return result[0]
}
public async setItemsDirty(
itemsToLookupUuidsFor: Models.DecryptedItemInterface[],
isUserModified = false,
): Promise<Models.DecryptedItemInterface[]> {
return this.changeItems(
itemsToLookupUuidsFor,
undefined,
isUserModified ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
)
}
/**
* Returns an array of items that need to be synced.
*/
@@ -760,47 +532,6 @@ export class ItemManager
return this.collection.dirtyElements().filter(Models.isDecryptedOrDeletedItem)
}
/**
* Duplicates an item and maps it, thus propagating the item to observers.
* @param isConflict - Whether to mark the duplicate as a conflict of the original.
*/
public async duplicateItem<T extends Models.DecryptedItemInterface>(
itemToLookupUuidFor: T,
isConflict = false,
additionalContent?: Partial<Models.ItemContent>,
) {
const item = this.findSureItem(itemToLookupUuidFor.uuid)
const payload = item.payload.copy()
const resultingPayloads = Models.PayloadsByDuplicating({
payload,
baseCollection: this.payloadManager.getMasterCollection(),
isConflict,
additionalContent,
})
await this.payloadManager.emitPayloads(resultingPayloads, Models.PayloadEmitSource.LocalChanged)
const duplicate = this.findSureItem<T>(resultingPayloads[0].uuid)
return duplicate
}
public async createItem<T extends Models.DecryptedItemInterface, C extends Models.ItemContent = Models.ItemContent>(
contentType: ContentType,
content: C,
needsSync = false,
): Promise<T> {
const payload = new Models.DecryptedPayload<C>({
uuid: UuidGenerator.GenerateUuid(),
content_type: contentType,
content: Models.FillItemContent<C>(content),
dirty: needsSync,
...Models.PayloadTimestampDefaults(),
})
await this.payloadManager.emitPayload(payload, Models.PayloadEmitSource.LocalInserted)
return this.findSureItem<T>(payload.uuid)
}
public createTemplateItem<
C extends Models.ItemContent = Models.ItemContent,
I extends Models.DecryptedItemInterface<C> = Models.DecryptedItemInterface<C>,
@@ -824,75 +555,6 @@ export class ItemManager
return !this.findItem(item.uuid)
}
public async insertItem(item: Models.DecryptedItemInterface): Promise<Models.DecryptedItemInterface> {
return this.emitItemFromPayload(item.payload, Models.PayloadEmitSource.LocalChanged)
}
public async insertItems(
items: Models.DecryptedItemInterface[],
emitSource: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalInserted,
): Promise<Models.DecryptedItemInterface[]> {
return this.emitItemsFromPayloads(
items.map((item) => item.payload),
emitSource,
)
}
public async emitItemFromPayload(
payload: Models.DecryptedPayloadInterface,
emitSource: Models.PayloadEmitSource,
): Promise<Models.DecryptedItemInterface> {
await this.payloadManager.emitPayload(payload, emitSource)
return this.findSureItem(payload.uuid)
}
public async emitItemsFromPayloads(
payloads: Models.DecryptedPayloadInterface[],
emitSource: Models.PayloadEmitSource,
): Promise<Models.DecryptedItemInterface[]> {
await this.payloadManager.emitPayloads(payloads, emitSource)
const uuids = Uuids(payloads)
return this.findItems(uuids)
}
public async setItemToBeDeleted(
itemToLookupUuidFor: Models.DecryptedItemInterface | Models.EncryptedItemInterface,
source: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalChanged,
): Promise<void> {
const referencingIdsCapturedBeforeChanges = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid)
const item = this.findAnyItem(itemToLookupUuidFor.uuid)
if (!item) {
return
}
const mutator = new Models.DeleteItemMutator(item, Models.MutationType.UpdateUserTimestamps)
const deletedPayload = mutator.getDeletedResult()
await this.payloadManager.emitPayload(deletedPayload, source)
for (const referencingId of referencingIdsCapturedBeforeChanges) {
const referencingItem = this.findItem(referencingId)
if (referencingItem) {
await this.changeItem(referencingItem, (mutator) => {
mutator.removeItemAsRelationship(item)
})
}
}
}
public async setItemsToBeDeleted(
itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[],
): Promise<void> {
await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item)))
}
public getItems<T extends Models.DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[] {
return this.collection.allDecrypted<T>(contentType)
}
@@ -1018,20 +680,6 @@ export class ItemManager
return chain
}
public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise<Models.SNTag> {
let current: Models.SNTag | undefined = undefined
for (const title of titlesHierarchy) {
current = await this.findOrCreateTagByTitle(title, current)
}
if (!current) {
throw new Error('Invalid tag hierarchy')
}
return current
}
public getTagChildren(itemToLookupUuidFor: Models.SNTag): Models.SNTag[] {
const tag = this.findItem<Models.SNTag>(itemToLookupUuidFor.uuid)
if (!tag) {
@@ -1079,117 +727,12 @@ export class ItemManager
return true
}
/**
* @returns The changed child tag
*/
public setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise<Models.SNTag> {
if (parentTag.uuid === childTag.uuid) {
throw new Error('Can not set a tag parent of itself')
}
if (this.isTagAncestor(childTag, parentTag)) {
throw new Error('Can not set a tag ancestor of itself')
}
return this.changeTag(childTag, (m) => {
m.makeChildOf(parentTag)
})
}
/**
* @returns The changed child tag
*/
public unsetTagParent(childTag: Models.SNTag): Promise<Models.SNTag> {
const parentTag = this.getTagParent(childTag)
if (!parentTag) {
return Promise.resolve(childTag)
}
return this.changeTag(childTag, (m) => {
m.unsetParent()
})
}
public async associateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.addNote(note)
})
}
public async disassociateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.removeNote(note)
})
}
public async addTagToNote(note: Models.SNNote, tag: Models.SNTag, addHierarchy: boolean): Promise<Models.SNTag[]> {
let tagsToAdd = [tag]
if (addHierarchy) {
const parentChainTags = this.getTagParentChain(tag)
tagsToAdd = [...parentChainTags, tag]
}
return Promise.all(
tagsToAdd.map((tagToAdd) => {
return this.changeTag(tagToAdd, (mutator) => {
mutator.addNote(note)
}) as Promise<Models.SNTag>
}),
)
}
public async addTagToFile(file: Models.FileItem, tag: Models.SNTag, addHierarchy: boolean): Promise<Models.SNTag[]> {
let tagsToAdd = [tag]
if (addHierarchy) {
const parentChainTags = this.getTagParentChain(tag)
tagsToAdd = [...parentChainTags, tag]
}
return Promise.all(
tagsToAdd.map((tagToAdd) => {
return this.changeTag(tagToAdd, (mutator) => {
mutator.addFile(file)
}) as Promise<Models.SNTag>
}),
)
}
public async linkNoteToNote(note: Models.SNNote, otherNote: Models.SNNote): Promise<Models.SNNote> {
return this.changeItem<Models.NoteMutator, Models.SNNote>(note, (mutator) => {
mutator.addNote(otherNote)
})
}
public async linkFileToFile(file: Models.FileItem, otherFile: Models.FileItem): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.addFile(otherFile)
})
}
public async unlinkItems(itemA: DecryptedItemInterface<ItemContent>, itemB: DecryptedItemInterface<ItemContent>) {
const relationshipDirection = this.relationshipDirectionBetweenItems(itemA, itemB)
if (relationshipDirection === ItemRelationshipDirection.NoRelationship) {
throw new Error('Trying to unlink already unlinked items')
}
const itemToChange = relationshipDirection === ItemRelationshipDirection.AReferencesB ? itemA : itemB
const itemToRemove = itemToChange === itemA ? itemB : itemA
return this.changeItem(itemToChange, (mutator) => {
mutator.removeItemAsRelationship(itemToRemove)
})
}
/**
* Get tags for a note sorted in natural order
* @param item - The item whose tags will be returned
* @returns Array containing tags associated with an item
*/
public getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): Models.SNTag[] {
public getSortedTagsForItem(item: Models.DecryptedItemInterface<Models.ItemContent>): Models.SNTag[] {
return naturalSort(
this.itemsReferencingItem(item).filter((ref) => {
return ref?.content_type === ContentType.Tag
@@ -1198,81 +741,16 @@ export class ItemManager
)
}
public async createTag(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise<Models.SNTag> {
const newTag = await this.createItem<Models.SNTag>(
ContentType.Tag,
Models.FillItemContent<Models.TagContent>({ title }),
true,
)
if (parentItemToLookupUuidFor) {
const parentTag = this.findItem<Models.SNTag>(parentItemToLookupUuidFor.uuid)
if (!parentTag) {
throw new Error('Invalid parent tag')
}
return this.changeTag(newTag, (m) => {
m.makeChildOf(parentTag)
})
}
return newTag
}
public async createSmartView<T extends Models.DecryptedItemInterface>(
title: string,
predicate: Models.PredicateInterface<T>,
iconString?: string,
): Promise<Models.SmartView> {
return this.createItem(
ContentType.SmartView,
Models.FillItemContent({
title,
predicate: predicate.toJson(),
iconString: iconString || SmartViewDefaultIconName,
} as Models.SmartViewContent),
true,
) as Promise<Models.SmartView>
}
public async createSmartViewFromDSL<T extends Models.DecryptedItemInterface>(dsl: string): Promise<Models.SmartView> {
let components = null
try {
components = JSON.parse(dsl.substring(1, dsl.length))
} catch (e) {
throw Error('Invalid smart view syntax')
}
const title = components[0]
const predicate = Models.predicateFromDSLString<T>(dsl)
return this.createSmartView(title, predicate)
}
public async createTagOrSmartView(title: string): Promise<Models.SNTag | Models.SmartView> {
if (this.isSmartViewTitle(title)) {
return this.createSmartViewFromDSL(title)
} else {
return this.createTag(title)
}
}
public isSmartViewTitle(title: string): boolean {
return title.startsWith(Models.SMART_TAG_DSL_PREFIX)
}
/**
* Finds or creates a tag with a given title
*/
public async findOrCreateTagByTitle(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise<Models.SNTag> {
const tag = this.findTagByTitleAndParent(title, parentItemToLookupUuidFor)
return tag || this.createTag(title, parentItemToLookupUuidFor)
}
public notesMatchingSmartView(view: Models.SmartView): Models.SNNote[] {
const criteria: Models.FilterDisplayOptions = {
const criteria: Models.NotesAndFilesDisplayOptions = {
views: [view],
}
return Models.itemsMatchingOptions(
return Models.notesAndFilesMatchingOptions(
criteria,
this.collection.allDecrypted(ContentType.Note),
this.collection,
@@ -1299,14 +777,6 @@ export class ItemManager
return this.notesMatchingSmartView(this.trashSmartView)
}
/**
* Permanently deletes any items currently in the trash. Consumer must manually call sync.
*/
public async emptyTrash(): Promise<void> {
const notes = this.trashedItems
await this.setItemsToBeDeleted(notes)
}
/**
* Returns all smart views, sorted by title.
*/
@@ -1346,53 +816,29 @@ export class ItemManager
this.payloadManager.resetState()
}
public removeItemLocally(item: Models.DecryptedItemInterface | Models.DeletedItemInterface): void {
this.collection.discard([item])
this.payloadManager.removePayloadLocally(item.payload)
/**
* Important: Caller must coordinate with storage service separately to delete item from persistent database.
*/
public removeItemLocally(item: Models.AnyItemInterface): void {
this.removeItemsLocally([item])
}
const delta = Models.CreateItemDelta({ discarded: [item] as Models.DeletedItemInterface[] })
/**
* Important: Caller must coordinate with storage service separately to delete item from persistent database.
*/
public removeItemsLocally(items: Models.AnyItemInterface[]): void {
this.collection.discard(items)
this.payloadManager.removePayloadLocally(items.map((item) => item.payload))
const delta = Models.CreateItemDelta({ discarded: items as Models.DeletedItemInterface[] })
const affectedContentTypes = items.map((item) => item.content_type)
for (const controller of this.allDisplayControllers) {
if (controller.contentTypes.some((ct) => ct === item.content_type)) {
if (controller.contentTypes.some((ct) => affectedContentTypes.includes(ct))) {
controller.onCollectionChange(delta)
}
}
}
public renameFile(file: Models.FileItem, name: string): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.name = name
})
}
public async setLastSyncBeganForItems(
itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.DeletedItemInterface)[],
date: Date,
globalDirtyIndex: number,
): Promise<(Models.DecryptedItemInterface | Models.DeletedItemInterface)[]> {
const uuids = Uuids(itemsToLookupUuidsFor)
const items = this.collection.findAll(uuids).filter(Models.isDecryptedOrDeletedItem)
const payloads: (Models.DecryptedPayloadInterface | Models.DeletedPayloadInterface)[] = []
for (const item of items) {
const mutator = new Models.ItemMutator<Models.DecryptedPayloadInterface | Models.DeletedPayloadInterface>(
item,
Models.MutationType.NonDirtying,
)
mutator.setBeginSync(date, globalDirtyIndex)
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, Models.PayloadEmitSource.PreSyncSave)
return this.findAnyItems(uuids) as (Models.DecryptedItemInterface | Models.DeletedItemInterface)[]
}
public relationshipDirectionBetweenItems(
itemA: Models.DecryptedItemInterface<Models.ItemContent>,
itemB: Models.DecryptedItemInterface<Models.ItemContent>,
@@ -1407,12 +853,8 @@ export class ItemManager
: ItemRelationshipDirection.NoRelationship
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
items: {
allIds: Uuids(this.collection.all()),
},
})
itemsBelongingToKeySystem(systemIdentifier: Models.KeySystemIdentifier): Models.DecryptedItemInterface[] {
return this.items.filter((item) => item.key_system_identifier === systemIdentifier)
}
public conflictsOf(uuid: string) {
@@ -1422,4 +864,8 @@ export class ItemManager
public numberOfNotesWithConflicts(): number {
return this.findItems(this.collection.uuidsOfItemsWithConflicts()).filter(Models.isNote).length
}
getNoteLinkedFiles(note: Models.SNNote): Models.FileItem[] {
return this.itemsReferencingItem(note).filter(Models.isFile)
}
}

View File

@@ -312,7 +312,7 @@ export class SNKeyRecoveryService extends AbstractService<KeyRecoveryEvent, Decr
private addKeysToQueue(keys: EncryptedPayloadInterface[]) {
for (const key of keys) {
const keyParams = this.protocolService.getKeyEmbeddedKeyParams(key)
const keyParams = this.protocolService.getKeyEmbeddedKeyParamsFromItemsKey(key)
if (!keyParams) {
continue
}

View File

@@ -1,3 +1,4 @@
import { SyncClientInterface } from './../Sync/SyncClientInterface'
import { isString, lastElement, sleep } from '@standardnotes/utils'
import { UuidString } from '@Lib/Types/UuidString'
import { ContentType } from '@standardnotes/common'
@@ -19,7 +20,8 @@ export class ListedService extends AbstractService implements ListedClientInterf
private settingsService: SNSettingsService,
private httpSerivce: DeprecatedHttpService,
private protectionService: SNProtectionService,
private mutatorService: MutatorClientInterface,
private mutator: MutatorClientInterface,
private sync: SyncClientInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -31,7 +33,7 @@ export class ListedService extends AbstractService implements ListedClientInterf
;(this.apiService as unknown) = undefined
;(this.httpSerivce as unknown) = undefined
;(this.protectionService as unknown) = undefined
;(this.mutatorService as unknown) = undefined
;(this.mutator as unknown) = undefined
super.deinit()
}
@@ -49,10 +51,12 @@ export class ListedService extends AbstractService implements ListedClientInterf
return false
}
await this.mutatorService.changeAndSaveItem<NoteMutator>(note, (mutator) => {
await this.mutator.changeItem<NoteMutator>(note, (mutator) => {
mutator.authorizedForListed = true
})
void this.sync.sync()
return true
}

View File

@@ -1,16 +1,16 @@
import { SNHistoryManager } from './../History/HistoryManager'
import { NoteContent, SNNote, FillItemContent, DecryptedPayload, PayloadTimestampDefaults } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { EncryptionService, InternalEventBusInterface } from '@standardnotes/services'
import {
ChallengeService,
MutatorService,
PayloadManager,
SNComponentManager,
SNProtectionService,
ItemManager,
SNSyncService,
} from '../'
NoteContent,
SNNote,
FillItemContent,
DecryptedPayload,
PayloadTimestampDefaults,
MutationType,
FileItem,
SNTag,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { AlertService, InternalEventBusInterface } from '@standardnotes/services'
import { MutatorService, PayloadManager, ItemManager } from '../'
import { UuidGenerator } from '@standardnotes/utils'
const setupRandomUuid = () => {
@@ -21,12 +21,6 @@ describe('mutator service', () => {
let mutatorService: MutatorService
let payloadManager: PayloadManager
let itemManager: ItemManager
let syncService: SNSyncService
let protectionService: SNProtectionService
let protocolService: EncryptionService
let challengeService: ChallengeService
let componentManager: SNComponentManager
let historyService: SNHistoryManager
let internalEventBus: InternalEventBusInterface
@@ -38,17 +32,10 @@ describe('mutator service', () => {
payloadManager = new PayloadManager(internalEventBus)
itemManager = new ItemManager(payloadManager, internalEventBus)
mutatorService = new MutatorService(
itemManager,
syncService,
protectionService,
protocolService,
payloadManager,
challengeService,
componentManager,
historyService,
internalEventBus,
)
const alerts = {} as jest.Mocked<AlertService>
alerts.alert = jest.fn()
mutatorService = new MutatorService(itemManager, payloadManager, alerts, internalEventBus)
})
const insertNote = (title: string) => {
@@ -73,10 +60,76 @@ describe('mutator service', () => {
(mutator) => {
mutator.pinned = true
},
false,
MutationType.NoUpdateUserTimestamps,
)
expect(note.userModifiedDate).toEqual(pinnedNote?.userModifiedDate)
})
})
describe('linking', () => {
it('attempting to link file and note should not be allowed if items belong to different vaults', async () => {
const note = {
uuid: 'note',
key_system_identifier: '123',
} as jest.Mocked<SNNote>
const file = {
uuid: 'file',
key_system_identifier: '456',
} as jest.Mocked<FileItem>
const result = await mutatorService.associateFileWithNote(file, note)
expect(result).toBeUndefined()
})
it('attempting to link vaulted tag with non vaulted note should not be permissable', async () => {
const note = {
uuid: 'note',
key_system_identifier: undefined,
} as jest.Mocked<SNNote>
const tag = {
uuid: 'tag',
key_system_identifier: '456',
} as jest.Mocked<SNTag>
const result = await mutatorService.addTagToNote(note, tag, true)
expect(result).toBeUndefined()
})
it('attempting to link vaulted tag with non vaulted file should not be permissable', async () => {
const tag = {
uuid: 'tag',
key_system_identifier: '456',
} as jest.Mocked<SNTag>
const file = {
uuid: 'file',
key_system_identifier: undefined,
} as jest.Mocked<FileItem>
const result = await mutatorService.addTagToFile(file, tag, true)
expect(result).toBeUndefined()
})
it('attempting to link vaulted tag with note belonging to different vault should not be perpermissable', async () => {
const note = {
uuid: 'note',
key_system_identifier: '123',
} as jest.Mocked<SNNote>
const tag = {
uuid: 'tag',
key_system_identifier: '456',
} as jest.Mocked<SNTag>
const result = await mutatorService.addTagToNote(note, tag, true)
expect(result).toBeUndefined()
})
})
})

View File

@@ -1,62 +1,60 @@
import { SNHistoryManager } from './../History/HistoryManager'
import {
AbstractService,
InternalEventBusInterface,
SyncOptions,
ChallengeValidation,
ChallengePrompt,
ChallengeReason,
MutatorClientInterface,
Challenge,
InfoStrings,
ItemRelationshipDirection,
AlertService,
} from '@standardnotes/services'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ClientDisplayableError } from '@standardnotes/responses'
import { ContentType, ProtocolVersion, compareVersions } from '@standardnotes/common'
import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption'
import { ContentType } from '@standardnotes/common'
import { ItemManager } from '../Items'
import { PayloadManager } from '../Payloads/PayloadManager'
import { SNComponentManager } from '../ComponentManager/ComponentManager'
import { SNProtectionService } from '../Protection/ProtectionService'
import { SNSyncService } from '../Sync'
import { Strings } from '../../Strings'
import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders'
import { ChallengeService } from '../Challenge'
import {
BackupFile,
BackupFileDecryptedContextualPayload,
ComponentContent,
CopyPayloadWithContentOverride,
CreateDecryptedBackupFileContextPayload,
ActionsExtensionMutator,
ComponentMutator,
CreateDecryptedMutatorForItem,
CreateEncryptedBackupFileContextPayload,
DecryptedItemInterface,
DecryptedItemMutator,
DecryptedPayload,
DecryptedPayloadInterface,
DeleteItemMutator,
EncryptedItemInterface,
FeatureRepoMutator,
FileItem,
isDecryptedPayload,
isEncryptedTransferPayload,
FileMutator,
FillItemContent,
ItemContent,
ItemsKeyInterface,
ItemsKeyMutatorInterface,
MutationType,
NoteMutator,
PayloadEmitSource,
PayloadsByDuplicating,
PayloadTimestampDefaults,
PayloadVaultOverrides,
predicateFromDSLString,
PredicateInterface,
SmartView,
SmartViewContent,
SmartViewDefaultIconName,
SNActionsExtension,
SNComponent,
SNFeatureRepo,
SNNote,
SNTag,
TagContent,
TagMutator,
TransactionalMutation,
VaultListingInterface,
} from '@standardnotes/models'
import { UuidGenerator, Uuids } from '@standardnotes/utils'
export class MutatorService extends AbstractService implements MutatorClientInterface {
constructor(
private itemManager: ItemManager,
private syncService: SNSyncService,
private protectionService: SNProtectionService,
private encryption: EncryptionProviderInterface,
private payloadManager: PayloadManager,
private challengeService: ChallengeService,
private componentManager: SNComponentManager,
private historyService: SNHistoryManager,
private alerts: AlertService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -65,86 +63,98 @@ export class MutatorService extends AbstractService implements MutatorClientInte
override deinit() {
super.deinit()
;(this.itemManager as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.protectionService as unknown) = undefined
;(this.encryption as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.componentManager as unknown) = undefined
;(this.historyService as unknown) = undefined
}
public async insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface> {
const mutator = CreateDecryptedMutatorForItem(item, MutationType.UpdateUserTimestamps)
const dirtiedPayload = mutator.getResult()
const insertedItem = await this.itemManager.emitItemFromPayload(dirtiedPayload, PayloadEmitSource.LocalInserted)
return insertedItem
}
public async changeAndSaveItem<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<DecryptedItemInterface | undefined> {
await this.itemManager.changeItems(
/**
* Consumers wanting to modify an item should run it through this block,
* so that data is properly mapped through our function, and latest state
* is properly reconciled.
*/
public async changeItem<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemToLookupUuidFor: I,
mutate?: (mutator: M) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<I> {
const results = await this.changeItems<M, I>(
[itemToLookupUuidFor],
mutate,
updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
mutationType,
emitSource,
payloadSourceKey,
)
await this.syncService.sync(syncOptions)
return this.itemManager.findItem(itemToLookupUuidFor.uuid)
return results[0]
}
public async changeAndSaveItems<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<void> {
await this.itemManager.changeItems(
itemsToLookupUuidsFor,
mutate,
updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
emitSource,
)
await this.syncService.sync(syncOptions)
}
public async changeItem<M extends DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps = true,
): Promise<DecryptedItemInterface | undefined> {
await this.itemManager.changeItems(
[itemToLookupUuidFor],
mutate,
updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
)
return this.itemManager.findItem(itemToLookupUuidFor.uuid)
}
public async changeItems<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps = true,
): Promise<(DecryptedItemInterface | undefined)[]> {
return this.itemManager.changeItems(
itemsToLookupUuidsFor,
mutate,
updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
)
/**
* @param mutate If not supplied, the intention would simply be to mark the item as dirty.
*/
public async changeItems<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemsToLookupUuidsFor: I[],
mutate?: (mutator: M) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<I[]> {
const items = this.itemManager.findItemsIncludingBlanks(Uuids(itemsToLookupUuidsFor))
const payloads: DecryptedPayloadInterface[] = []
for (const item of items) {
if (!item) {
throw Error('Attempting to change non-existant item')
}
const mutator = CreateDecryptedMutatorForItem(item, mutationType)
if (mutate) {
mutate(mutator as M)
}
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey)
const results = this.itemManager.findItems(payloads.map((p) => p.uuid)) as I[]
return results
}
/**
* Run unique mutations per each item in the array, then only propagate all changes
* once all mutations have been run. This differs from `changeItems` in that changeItems
* runs the same mutation on all items.
*/
public async runTransactionalMutations(
transactions: TransactionalMutation[],
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<(DecryptedItemInterface | undefined)[]> {
return this.itemManager.runTransactionalMutations(transactions, emitSource, payloadSourceKey)
const payloads: DecryptedPayloadInterface[] = []
for (const transaction of transactions) {
const item = this.itemManager.findItem(transaction.itemUuid)
if (!item) {
continue
}
const mutator = CreateDecryptedMutatorForItem(item, transaction.mutationType || MutationType.UpdateUserTimestamps)
transaction.mutate(mutator)
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey)
const results = this.itemManager.findItems(payloads.map((p) => p.uuid))
return results
}
public async runTransactionalMutation(
@@ -152,97 +162,387 @@ export class MutatorService extends AbstractService implements MutatorClientInte
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<DecryptedItemInterface | undefined> {
return this.itemManager.runTransactionalMutation(transaction, emitSource, payloadSourceKey)
const item = this.itemManager.findSureItem(transaction.itemUuid)
const mutator = CreateDecryptedMutatorForItem(item, transaction.mutationType || MutationType.UpdateUserTimestamps)
transaction.mutate(mutator)
const payload = mutator.getResult()
await this.payloadManager.emitPayloads([payload], emitSource, payloadSourceKey)
const result = this.itemManager.findItem(payload.uuid)
return result
}
async protectItems<M extends DecryptedItemMutator, I extends DecryptedItemInterface>(items: I[]): Promise<I[]> {
const protectedItems = await this.itemManager.changeItems<M, I>(
items,
(mutator) => {
mutator.protected = true
},
MutationType.NoUpdateUserTimestamps,
)
async changeNote(
itemToLookupUuidFor: SNNote,
mutate: (mutator: NoteMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<DecryptedPayloadInterface[]> {
const note = this.itemManager.findItem<SNNote>(itemToLookupUuidFor.uuid)
if (!note) {
throw Error('Attempting to change non-existant note')
}
const mutator = new NoteMutator(note, mutationType)
void this.syncService.sync()
return protectedItems
return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
}
async unprotectItems<M extends DecryptedItemMutator, I extends DecryptedItemInterface>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined> {
if (
!(await this.protectionService.authorizeAction(reason, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
}))
) {
return undefined
async changeTag(
itemToLookupUuidFor: SNTag,
mutate: (mutator: TagMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<SNTag> {
const tag = this.itemManager.findItem<SNTag>(itemToLookupUuidFor.uuid)
if (!tag) {
throw Error('Attempting to change non-existant tag')
}
const mutator = new TagMutator(tag, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.itemManager.findSureItem<SNTag>(itemToLookupUuidFor.uuid)
}
async changeComponent(
itemToLookupUuidFor: SNComponent,
mutate: (mutator: ComponentMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<SNComponent> {
const component = this.itemManager.findItem<SNComponent>(itemToLookupUuidFor.uuid)
if (!component) {
throw Error('Attempting to change non-existant component')
}
const mutator = new ComponentMutator(component, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.itemManager.findSureItem<SNComponent>(itemToLookupUuidFor.uuid)
}
async changeFeatureRepo(
itemToLookupUuidFor: SNFeatureRepo,
mutate: (mutator: FeatureRepoMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<SNFeatureRepo> {
const repo = this.itemManager.findItem(itemToLookupUuidFor.uuid)
if (!repo) {
throw Error('Attempting to change non-existant repo')
}
const mutator = new FeatureRepoMutator(repo, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.itemManager.findSureItem<SNFeatureRepo>(itemToLookupUuidFor.uuid)
}
async changeActionsExtension(
itemToLookupUuidFor: SNActionsExtension,
mutate: (mutator: ActionsExtensionMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<SNActionsExtension> {
const extension = this.itemManager.findItem<SNActionsExtension>(itemToLookupUuidFor.uuid)
if (!extension) {
throw Error('Attempting to change non-existant extension')
}
const mutator = new ActionsExtensionMutator(extension, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.itemManager.findSureItem<SNActionsExtension>(itemToLookupUuidFor.uuid)
}
async changeItemsKey(
itemToLookupUuidFor: ItemsKeyInterface,
mutate: (mutator: ItemsKeyMutatorInterface) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<ItemsKeyInterface> {
const itemsKey = this.itemManager.findItem<SNItemsKey>(itemToLookupUuidFor.uuid)
if (!itemsKey) {
throw Error('Attempting to change non-existant itemsKey')
}
const unprotectedItems = await this.itemManager.changeItems<M, I>(
items,
(mutator) => {
mutator.protected = false
},
MutationType.NoUpdateUserTimestamps,
const mutator = new ItemsKeyMutator(itemsKey, mutationType)
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
return this.itemManager.findSureItem<ItemsKeyInterface>(itemToLookupUuidFor.uuid)
}
private async applyTransform<T extends DecryptedItemMutator>(
mutator: T,
mutate: (mutator: T) => void,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<DecryptedPayloadInterface[]> {
mutate(mutator)
const payload = mutator.getResult()
return this.payloadManager.emitPayload(payload, emitSource, payloadSourceKey)
}
/**
* Sets the item as needing sync. The item is then run through the mapping function,
* and propagated to mapping observers.
* @param isUserModified - Whether to update the item's "user modified date"
*/
public async setItemDirty(itemToLookupUuidFor: DecryptedItemInterface, isUserModified = false) {
const result = await this.setItemsDirty([itemToLookupUuidFor], isUserModified)
return result[0]
}
public async setItemsDirty(
itemsToLookupUuidsFor: DecryptedItemInterface[],
isUserModified = false,
): Promise<DecryptedItemInterface[]> {
return this.changeItems(
itemsToLookupUuidsFor,
undefined,
isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
)
}
/**
* Duplicates an item and maps it, thus propagating the item to observers.
* @param isConflict - Whether to mark the duplicate as a conflict of the original.
*/
public async duplicateItem<T extends DecryptedItemInterface>(
itemToLookupUuidFor: T,
isConflict = false,
additionalContent?: Partial<ItemContent>,
) {
const item = this.itemManager.findSureItem(itemToLookupUuidFor.uuid)
const payload = item.payload.copy()
const resultingPayloads = PayloadsByDuplicating({
payload,
baseCollection: this.payloadManager.getMasterCollection(),
isConflict,
additionalContent,
})
await this.payloadManager.emitPayloads(resultingPayloads, PayloadEmitSource.LocalChanged)
const duplicate = this.itemManager.findSureItem<T>(resultingPayloads[0].uuid)
return duplicate
}
public async createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
contentType: ContentType,
content: C,
needsSync = false,
vault?: VaultListingInterface,
): Promise<T> {
const payload = new DecryptedPayload<C>({
uuid: UuidGenerator.GenerateUuid(),
content_type: contentType,
content: FillItemContent<C>(content),
dirty: needsSync,
...PayloadVaultOverrides(vault),
...PayloadTimestampDefaults(),
})
await this.payloadManager.emitPayload(payload, PayloadEmitSource.LocalInserted)
return this.itemManager.findSureItem<T>(payload.uuid)
}
public async insertItem<T extends DecryptedItemInterface>(item: DecryptedItemInterface, setDirty = true): Promise<T> {
if (setDirty) {
const mutator = CreateDecryptedMutatorForItem(item, MutationType.UpdateUserTimestamps)
const dirtiedPayload = mutator.getResult()
const insertedItem = await this.emitItemFromPayload<T>(dirtiedPayload, PayloadEmitSource.LocalInserted)
return insertedItem
} else {
return this.emitItemFromPayload(item.payload, PayloadEmitSource.LocalChanged)
}
}
public async insertItems(
items: DecryptedItemInterface[],
emitSource: PayloadEmitSource = PayloadEmitSource.LocalInserted,
): Promise<DecryptedItemInterface[]> {
return this.emitItemsFromPayloads(
items.map((item) => item.payload),
emitSource,
)
}
public async emitItemFromPayload<T extends DecryptedItemInterface>(
payload: DecryptedPayloadInterface,
emitSource: PayloadEmitSource,
): Promise<T> {
await this.payloadManager.emitPayload(payload, emitSource)
const result = this.itemManager.findSureItem<T>(payload.uuid)
if (!result) {
throw Error("Emitted item can't be found")
}
return result
}
public async emitItemsFromPayloads(
payloads: DecryptedPayloadInterface[],
emitSource: PayloadEmitSource,
): Promise<DecryptedItemInterface[]> {
await this.payloadManager.emitPayloads(payloads, emitSource)
const uuids = Uuids(payloads)
return this.itemManager.findItems(uuids)
}
public async setItemToBeDeleted(
itemToLookupUuidFor: DecryptedItemInterface | EncryptedItemInterface,
source: PayloadEmitSource = PayloadEmitSource.LocalChanged,
): Promise<void> {
const referencingIdsCapturedBeforeChanges = this.itemManager
.getCollection()
.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid)
const item = this.itemManager.findAnyItem(itemToLookupUuidFor.uuid)
if (!item) {
return
}
const mutator = new DeleteItemMutator(item, MutationType.UpdateUserTimestamps)
const deletedPayload = mutator.getDeletedResult()
await this.payloadManager.emitPayload(deletedPayload, source)
for (const referencingId of referencingIdsCapturedBeforeChanges) {
const referencingItem = this.itemManager.findItem(referencingId)
if (referencingItem) {
await this.changeItem(referencingItem, (mutator) => {
mutator.removeItemAsRelationship(item)
})
}
}
}
public async setItemsToBeDeleted(
itemsToLookupUuidsFor: (DecryptedItemInterface | EncryptedItemInterface)[],
): Promise<void> {
await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item)))
}
public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise<SNTag> {
let current: SNTag | undefined = undefined
for (const title of titlesHierarchy) {
current = await this.findOrCreateTagByTitle({ title, parentItemToLookupUuidFor: current })
}
if (!current) {
throw new Error('Invalid tag hierarchy')
}
return current
}
public async createTag(dto: {
title: string
parentItemToLookupUuidFor?: SNTag
createInVault?: VaultListingInterface
}): Promise<SNTag> {
const newTag = await this.createItem<SNTag>(
ContentType.Tag,
FillItemContent<TagContent>({ title: dto.title }),
true,
dto.createInVault,
)
void this.syncService.sync()
return unprotectedItems
if (dto.parentItemToLookupUuidFor) {
const parentTag = this.itemManager.findItem<SNTag>(dto.parentItemToLookupUuidFor.uuid)
if (!parentTag) {
throw new Error('Invalid parent tag')
}
return this.changeTag(newTag, (m) => {
m.makeChildOf(parentTag)
})
}
return newTag
}
public async protectNote(note: SNNote): Promise<SNNote> {
const result = await this.protectItems([note])
return result[0]
public async createSmartView<T extends DecryptedItemInterface>(dto: {
title: string
predicate: PredicateInterface<T>
iconString?: string
vault?: VaultListingInterface
}): Promise<SmartView> {
return this.createItem(
ContentType.SmartView,
FillItemContent({
title: dto.title,
predicate: dto.predicate.toJson(),
iconString: dto.iconString || SmartViewDefaultIconName,
} as SmartViewContent),
true,
dto.vault,
) as Promise<SmartView>
}
public async unprotectNote(note: SNNote): Promise<SNNote | undefined> {
const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote)
return result ? result[0] : undefined
public async createSmartViewFromDSL<T extends DecryptedItemInterface>(
dsl: string,
vault?: VaultListingInterface,
): Promise<SmartView> {
let components = null
try {
components = JSON.parse(dsl.substring(1, dsl.length))
} catch (e) {
throw Error('Invalid smart view syntax')
}
const title = components[0]
const predicate = predicateFromDSLString<T>(dsl)
return this.createSmartView({ title, predicate, vault })
}
public async protectNotes(notes: SNNote[]): Promise<SNNote[]> {
return this.protectItems(notes)
public async createTagOrSmartView<T extends SNTag | SmartView>(
title: string,
vault?: VaultListingInterface,
): Promise<T> {
if (this.itemManager.isSmartViewTitle(title)) {
return this.createSmartViewFromDSL(title, vault) as Promise<T>
} else {
return this.createTag({ title, createInVault: vault }) as Promise<T>
}
}
public async unprotectNotes(notes: SNNote[]): Promise<SNNote[]> {
const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote)
return results || []
public async findOrCreateTagByTitle(dto: {
title: string
parentItemToLookupUuidFor?: SNTag
createInVault?: VaultListingInterface
}): Promise<SNTag> {
const tag = this.itemManager.findTagByTitleAndParent(dto.title, dto.parentItemToLookupUuidFor)
return tag || this.createTag(dto)
}
async protectFile(file: FileItem): Promise<FileItem> {
const result = await this.protectItems([file])
return result[0]
}
async unprotectFile(file: FileItem): Promise<FileItem | undefined> {
const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile)
return result ? result[0] : undefined
public renameFile(file: FileItem, name: string): Promise<FileItem> {
return this.changeItem<FileMutator, FileItem>(file, (mutator) => {
mutator.name = name
})
}
public async mergeItem(item: DecryptedItemInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface> {
return this.itemManager.emitItemFromPayload(item.payloadRepresentation(), source)
}
public createTemplateItem<
C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(contentType: ContentType, content?: C, override?: Partial<DecryptedPayload<C>>): I {
return this.itemManager.createTemplateItem(contentType, content, override)
return this.emitItemFromPayload(item.payloadRepresentation(), source)
}
public async setItemNeedsSync(
item: DecryptedItemInterface,
updateTimestamps = false,
): Promise<DecryptedItemInterface | undefined> {
return this.itemManager.setItemDirty(item, updateTimestamps)
return this.setItemDirty(item, updateTimestamps)
}
public async setItemsNeedsSync(items: DecryptedItemInterface[]): Promise<(DecryptedItemInterface | undefined)[]> {
return this.itemManager.setItemsDirty(items)
return this.setItemsDirty(items)
}
public async deleteItem(item: DecryptedItemInterface | EncryptedItemInterface): Promise<void> {
@@ -250,153 +550,150 @@ export class MutatorService extends AbstractService implements MutatorClientInte
}
public async deleteItems(items: (DecryptedItemInterface | EncryptedItemInterface)[]): Promise<void> {
await this.itemManager.setItemsToBeDeleted(items)
await this.syncService.sync()
await this.setItemsToBeDeleted(items)
}
/**
* Permanently deletes any items currently in the trash. Consumer must manually call sync.
*/
public async emptyTrash(): Promise<void> {
await this.itemManager.emptyTrash()
await this.syncService.sync()
const notes = this.itemManager.trashedItems
await this.setItemsToBeDeleted(notes)
}
public duplicateItem<T extends DecryptedItemInterface>(
item: T,
additionalContent?: Partial<T['content']>,
): Promise<T> {
const duplicate = this.itemManager.duplicateItem<T>(item, false, additionalContent)
void this.syncService.sync()
return duplicate
public async migrateTagsToFolders(): Promise<void> {
await TagsToFoldersMigrationApplicator.run(this.itemManager, this)
}
public async migrateTagsToFolders(): Promise<unknown> {
await TagsToFoldersMigrationApplicator.run(this.itemManager)
return this.syncService.sync()
public async findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise<SNTag> {
return this.findOrCreateTagByTitle({ title, createInVault })
}
public async setTagParent(parentTag: SNTag, childTag: SNTag): Promise<void> {
await this.itemManager.setTagParent(parentTag, childTag)
}
public async unsetTagParent(childTag: SNTag): Promise<void> {
await this.itemManager.unsetTagParent(childTag)
}
public async findOrCreateTag(title: string): Promise<SNTag> {
return this.itemManager.findOrCreateTagByTitle(title)
}
/** Creates and returns the tag but does not run sync. Callers must perform sync. */
public async createTagOrSmartView(title: string): Promise<SNTag | SmartView> {
return this.itemManager.createTagOrSmartView(title)
}
public async toggleComponent(component: SNComponent): Promise<void> {
await this.componentManager.toggleComponent(component.uuid)
await this.syncService.sync()
}
public async toggleTheme(theme: SNComponent): Promise<void> {
await this.componentManager.toggleTheme(theme.uuid)
await this.syncService.sync()
}
public async importData(
data: BackupFile,
awaitSync = false,
): Promise<
| {
affectedItems: DecryptedItemInterface[]
errorCount: number
}
| {
error: ClientDisplayableError
}
> {
if (data.version) {
/**
* Prior to 003 backup files did not have a version field so we cannot
* stop importing if there is no backup file version, only if there is
* an unsupported version.
*/
const version = data.version as ProtocolVersion
const supportedVersions = this.encryption.supportedVersions()
if (!supportedVersions.includes(version)) {
return { error: new ClientDisplayableError(InfoStrings.UnsupportedBackupFileVersion) }
}
const userVersion = this.encryption.getUserVersion()
if (userVersion && compareVersions(version, userVersion) === 1) {
/** File was made with a greater version than the user's account */
return { error: new ClientDisplayableError(InfoStrings.BackupFileMoreRecentThanAccount) }
}
/**
* @returns The changed child tag
*/
public async setTagParent(parentTag: SNTag, childTag: SNTag): Promise<SNTag> {
if (parentTag.uuid === childTag.uuid) {
throw new Error('Can not set a tag parent of itself')
}
let password: string | undefined
if (data.auth_params || data.keyParams) {
/** Get import file password. */
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, Strings.Input.FileAccountPassword, undefined, true)],
ChallengeReason.DecryptEncryptedFile,
true,
)
const passwordResponse = await this.challengeService.promptForChallengeResponse(challenge)
if (passwordResponse == undefined) {
/** Challenge was canceled */
return { error: new ClientDisplayableError('Import aborted') }
}
this.challengeService.completeChallenge(challenge)
password = passwordResponse?.values[0].value as string
if (this.itemManager.isTagAncestor(childTag, parentTag)) {
throw new Error('Can not set a tag ancestor of itself')
}
if (!(await this.protectionService.authorizeFileImport())) {
return { error: new ClientDisplayableError('Import aborted') }
}
data.items = data.items.map((item) => {
if (isEncryptedTransferPayload(item)) {
return CreateEncryptedBackupFileContextPayload(item)
} else {
return CreateDecryptedBackupFileContextPayload(item as BackupFileDecryptedContextualPayload)
}
return this.changeTag(childTag, (m) => {
m.makeChildOf(parentTag)
})
}
const decryptedPayloadsOrError = await this.encryption.decryptBackupFile(data, password)
/**
* @returns The changed child tag
*/
public unsetTagParent(childTag: SNTag): Promise<SNTag> {
const parentTag = this.itemManager.getTagParent(childTag)
if (decryptedPayloadsOrError instanceof ClientDisplayableError) {
return { error: decryptedPayloadsOrError }
if (!parentTag) {
return Promise.resolve(childTag)
}
const validPayloads = decryptedPayloadsOrError.filter(isDecryptedPayload).map((payload) => {
/* Don't want to activate any components during import process in
* case of exceptions breaking up the import proccess */
if (payload.content_type === ContentType.Component && (payload.content as ComponentContent).active) {
const typedContent = payload as DecryptedPayloadInterface<ComponentContent>
return CopyPayloadWithContentOverride(typedContent, {
active: false,
})
} else {
return payload
}
return this.changeTag(childTag, (m) => {
m.unsetParent()
})
}
const affectedUuids = await this.payloadManager.importPayloads(
validPayloads,
this.historyService.getHistoryMapCopy(),
public async associateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem | undefined> {
const isVaultConflict =
file.key_system_identifier &&
note.key_system_identifier &&
file.key_system_identifier !== note.key_system_identifier
if (isVaultConflict) {
void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked')
return undefined
}
return this.changeItem<FileMutator, FileItem>(file, (mutator) => {
mutator.addNote(note)
})
}
public async disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem> {
return this.changeItem<FileMutator, FileItem>(file, (mutator) => {
mutator.removeNote(note)
})
}
public async addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[] | undefined> {
if (tag.key_system_identifier !== note.key_system_identifier) {
void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked')
return undefined
}
let tagsToAdd = [tag]
if (addHierarchy) {
const parentChainTags = this.itemManager.getTagParentChain(tag)
tagsToAdd = [...parentChainTags, tag]
}
return Promise.all(
tagsToAdd.map((tagToAdd) => {
return this.changeTag(tagToAdd, (mutator) => {
mutator.addNote(note)
}) as Promise<SNTag>
}),
)
}
const promise = this.syncService.sync()
if (awaitSync) {
await promise
public async addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise<SNTag[] | undefined> {
if (tag.key_system_identifier !== file.key_system_identifier) {
void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked')
return undefined
}
const affectedItems = this.itemManager.findItems(affectedUuids) as DecryptedItemInterface[]
let tagsToAdd = [tag]
return {
affectedItems: affectedItems,
errorCount: decryptedPayloadsOrError.length - validPayloads.length,
if (addHierarchy) {
const parentChainTags = this.itemManager.getTagParentChain(tag)
tagsToAdd = [...parentChainTags, tag]
}
return Promise.all(
tagsToAdd.map((tagToAdd) => {
return this.changeTag(tagToAdd, (mutator) => {
mutator.addFile(file)
}) as Promise<SNTag>
}),
)
}
public async linkNoteToNote(note: SNNote, otherNote: SNNote): Promise<SNNote> {
return this.changeItem<NoteMutator, SNNote>(note, (mutator) => {
mutator.addNote(otherNote)
})
}
public async linkFileToFile(file: FileItem, otherFile: FileItem): Promise<FileItem> {
return this.changeItem<FileMutator, FileItem>(file, (mutator) => {
mutator.addFile(otherFile)
})
}
public async unlinkItems(
itemA: DecryptedItemInterface<ItemContent>,
itemB: DecryptedItemInterface<ItemContent>,
): Promise<DecryptedItemInterface<ItemContent>> {
const relationshipDirection = this.itemManager.relationshipDirectionBetweenItems(itemA, itemB)
if (relationshipDirection === ItemRelationshipDirection.NoRelationship) {
throw new Error('Trying to unlink already unlinked items')
}
const itemToChange = relationshipDirection === ItemRelationshipDirection.AReferencesB ? itemA : itemB
const itemToRemove = itemToChange === itemA ? itemB : itemA
return this.changeItem(itemToChange, (mutator) => {
mutator.removeItemAsRelationship(itemToRemove)
})
}
}

View File

@@ -300,7 +300,7 @@ export class PayloadManager extends AbstractService implements PayloadManagerInt
return Uuids(payloads)
}
public removePayloadLocally(payload: FullyFormedPayloadInterface) {
public removePayloadLocally(payload: FullyFormedPayloadInterface | FullyFormedPayloadInterface[]): void {
this.collection.discard(payload)
}

View File

@@ -10,6 +10,7 @@ import {
ApplicationStage,
PreferenceServiceInterface,
PreferencesServiceEvent,
MutatorClientInterface,
} from '@standardnotes/services'
export class SNPreferencesService
@@ -24,7 +25,8 @@ export class SNPreferencesService
constructor(
private singletonManager: SNSingletonManager,
private itemManager: ItemManager,
itemManager: ItemManager,
private mutator: MutatorClientInterface,
private syncService: SNSyncService,
protected override internalEventBus: InternalEventBusInterface,
) {
@@ -45,7 +47,7 @@ export class SNPreferencesService
this.removeItemObserver?.()
this.removeSyncObserver?.()
;(this.singletonManager as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.mutator as unknown) = undefined
super.deinit()
}
@@ -77,7 +79,7 @@ export class SNPreferencesService
return
}
this.preferences = (await this.itemManager.changeItem<UserPrefsMutator>(this.preferences, (m) => {
this.preferences = (await this.mutator.changeItem<UserPrefsMutator>(this.preferences, (m) => {
m.setPref(key, value)
})) as SNUserPrefs

View File

@@ -6,6 +6,7 @@ import {
InternalEventBusInterface,
ChallengeReason,
EncryptionService,
MutatorClientInterface,
} from '@standardnotes/services'
import { UuidGenerator } from '@standardnotes/utils'
import {
@@ -22,6 +23,7 @@ const setupRandomUuid = () => {
}
describe('protectionService', () => {
let mutator: MutatorClientInterface
let protocolService: EncryptionService
let challengeService: ChallengeService
let storageService: DiskStorageService
@@ -29,7 +31,7 @@ describe('protectionService', () => {
let protectionService: SNProtectionService
const createService = () => {
return new SNProtectionService(protocolService, challengeService, storageService, internalEventBus)
return new SNProtectionService(protocolService, mutator, challengeService, storageService, internalEventBus)
}
const createFile = (name: string, isProtected?: boolean) => {
@@ -60,6 +62,8 @@ describe('protectionService', () => {
protocolService = {} as jest.Mocked<EncryptionService>
protocolService.hasAccount = jest.fn().mockReturnValue(true)
protocolService.hasPasscode = jest.fn().mockReturnValue(false)
mutator = {} as jest.Mocked<MutatorClientInterface>
})
describe('files', () => {

View File

@@ -1,6 +1,13 @@
import { ChallengeService } from './../Challenge/ChallengeService'
import { SNLog } from '@Lib/Log'
import { DecryptedItem } from '@standardnotes/models'
import {
DecryptedItem,
DecryptedItemInterface,
DecryptedItemMutator,
FileItem,
MutationType,
SNNote,
} from '@standardnotes/models'
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
import { isNullOrUndefined } from '@standardnotes/utils'
import {
@@ -9,7 +16,6 @@ import {
StorageValueModes,
ApplicationStage,
StorageKey,
DiagnosticInfo,
Challenge,
ChallengeReason,
ChallengePrompt,
@@ -18,6 +24,7 @@ import {
MobileUnlockTiming,
TimingDisplayOption,
ProtectionsClientInterface,
MutatorClientInterface,
} from '@standardnotes/services'
import { ContentType } from '@standardnotes/common'
@@ -70,6 +77,7 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
constructor(
private protocolService: EncryptionService,
private mutator: MutatorClientInterface,
private challengeService: ChallengeService,
private storageService: DiskStorageService,
protected override internalEventBus: InternalEventBusInterface,
@@ -435,15 +443,69 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
this.sessionExpiryTimeout = setTimeout(timer, expiryDate.getTime() - Date.now())
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
protections: {
getSessionExpiryDate: this.getSessionExpiryDate(),
getLastSessionLength: this.getLastSessionLength(),
hasProtectionSources: this.hasProtectionSources(),
hasUnprotectedAccessSession: this.hasUnprotectedAccessSession(),
hasBiometricsEnabled: this.hasBiometricsEnabled(),
async protectItems<I extends DecryptedItemInterface>(items: I[]): Promise<I[]> {
const protectedItems = await this.mutator.changeItems<DecryptedItemMutator, I>(
items,
(mutator) => {
mutator.protected = true
},
})
MutationType.NoUpdateUserTimestamps,
)
return protectedItems
}
async unprotectItems<I extends DecryptedItemInterface>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined> {
if (
!(await this.authorizeAction(reason, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
}))
) {
return undefined
}
const unprotectedItems = await this.mutator.changeItems<DecryptedItemMutator, I>(
items,
(mutator) => {
mutator.protected = false
},
MutationType.NoUpdateUserTimestamps,
)
return unprotectedItems
}
public async protectNote(note: SNNote): Promise<SNNote> {
const result = await this.protectItems([note])
return result[0]
}
public async unprotectNote(note: SNNote): Promise<SNNote | undefined> {
const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote)
return result ? result[0] : undefined
}
public async protectNotes(notes: SNNote[]): Promise<SNNote[]> {
return this.protectItems(notes)
}
public async unprotectNotes(notes: SNNote[]): Promise<SNNote[]> {
const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote)
return results || []
}
async protectFile(file: FileItem): Promise<FileItem> {
const result = await this.protectItems([file])
return result[0]
}
async unprotectFile(file: FileItem): Promise<FileItem | undefined> {
const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile)
return result ? result[0] : undefined
}
}

View File

@@ -3,7 +3,6 @@ import {
AbstractService,
InternalEventBusInterface,
StorageKey,
DiagnosticInfo,
ChallengePrompt,
ChallengeValidation,
ChallengeKeyboardType,
@@ -26,8 +25,12 @@ import {
InternalEventInterface,
ApiServiceEvent,
SessionRefreshedData,
SessionEvent,
UserKeyPairChangedEventData,
InternalFeatureService,
InternalFeature,
} from '@standardnotes/services'
import { Base64String } from '@standardnotes/sncrypto-common'
import { Base64String, PkcKeyPair } from '@standardnotes/sncrypto-common'
import {
ClientDisplayableError,
SessionBody,
@@ -43,7 +46,7 @@ import {
SessionListResponse,
HttpSuccessResponse,
} from '@standardnotes/responses'
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
import { CopyPayloadWithContentOverride, RootKeyWithKeyPairsInterface } from '@standardnotes/models'
import { LegacySession, MapperInterface, Result, Session, SessionToken } from '@standardnotes/domain-core'
import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey } from '@standardnotes/encryption'
import { Subscription } from '@standardnotes/security'
@@ -56,7 +59,7 @@ import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNWebSocketsService } from '../Api/WebsocketsService'
import { Strings } from '@Lib/Strings'
import { UuidString } from '@Lib/Types/UuidString'
import { ChallengeService } from '../Challenge'
import { ChallengeResponse, ChallengeService } from '../Challenge'
import {
ApiCallError,
ErrorMessage,
@@ -72,11 +75,6 @@ const cleanedEmailString = (email: string) => {
return email.trim().toLowerCase()
}
export enum SessionEvent {
Restored = 'SessionRestored',
Revoked = 'SessionRevoked',
}
/**
* The session manager is responsible for loading initial user state, and any relevant
* server credentials, such as the session token. It also exposes methods for registering
@@ -139,18 +137,19 @@ export class SNSessionManager
}
}
private setUser(user?: User) {
private memoizeUser(user?: User) {
this.user = user
this.apiService.setUser(user)
}
async initializeFromDisk() {
this.setUser(this.diskStorageService.getValue(StorageKey.User))
this.memoizeUser(this.diskStorageService.getValue(StorageKey.User))
if (!this.user) {
const legacyUuidLookup = this.diskStorageService.getValue<string>(StorageKey.LegacyUuid)
if (legacyUuidLookup) {
this.setUser({ uuid: legacyUuidLookup, email: legacyUuidLookup })
this.memoizeUser({ uuid: legacyUuidLookup, email: legacyUuidLookup })
}
}
@@ -193,6 +192,36 @@ export class SNSessionManager
return this.user
}
public getSureUser(): User {
return this.user as User
}
isUserMissingKeyPair(): boolean {
try {
return this.getPublicKey() == undefined
} catch (error) {
return true
}
}
public getPublicKey(): string {
return this.protocolService.getKeyPair().publicKey
}
public getSigningPublicKey(): string {
return this.protocolService.getSigningKeyPair().publicKey
}
public get userUuid(): string {
const user = this.getUser()
if (!user) {
throw Error('Attempting to access userUuid when user is undefined')
}
return user.uuid
}
isCurrentSessionReadOnly(): boolean | undefined {
if (this.session === undefined) {
return undefined
@@ -205,16 +234,13 @@ export class SNSessionManager
return this.session.isReadOnly()
}
public getSureUser() {
return this.user as User
}
public getSession() {
return this.apiService.getSession()
}
public async signOut() {
this.setUser(undefined)
this.memoizeUser(undefined)
const session = this.apiService.getSession()
if (session && session instanceof Session) {
await this.apiService.signOut()
@@ -268,7 +294,11 @@ export class SNSessionManager
currentKeyParams?.version,
)
if (isErrorResponse(response)) {
this.challengeService.setValidationStatusForChallenge(challenge, challengeResponse!.values[1], false)
this.challengeService.setValidationStatusForChallenge(
challenge,
(challengeResponse as ChallengeResponse).values[1],
false,
)
onResponse?.(response)
} else {
resolve()
@@ -373,11 +403,20 @@ export class SNSessionManager
email = cleanedEmailString(email)
const rootKey = await this.protocolService.createRootKey(email, password, Common.KeyParamsOrigination.Registration)
const rootKey = await this.protocolService.createRootKey<RootKeyWithKeyPairsInterface>(
email,
password,
Common.KeyParamsOrigination.Registration,
)
const serverPassword = rootKey.serverPassword as string
const keyParams = rootKey.keyParams
const registerResponse = await this.userApiService.register({ email, serverPassword, keyParams, ephemeral })
const registerResponse = await this.userApiService.register({
email,
serverPassword,
keyParams,
ephemeral,
})
if ('error' in registerResponse.data) {
throw new ApiCallError(registerResponse.data.error.message)
@@ -485,7 +524,7 @@ export class SNSessionManager
response: paramsResult.response,
}
}
const keyParams = paramsResult.keyParams!
const keyParams = paramsResult.keyParams as SNRootKeyParams
if (!this.protocolService.supportedVersions().includes(keyParams.version)) {
if (this.protocolService.isVersionNewerThanLibraryVersion(keyParams.version)) {
return {
@@ -563,7 +602,7 @@ export class SNSessionManager
const signInResponse = await this.apiService.signIn({
email,
serverPassword: rootKey.serverPassword!,
serverPassword: rootKey.serverPassword as string,
ephemeral,
})
@@ -585,20 +624,49 @@ export class SNSessionManager
public async changeCredentials(parameters: {
currentServerPassword: string
newRootKey: SNRootKey
newRootKey: RootKeyWithKeyPairsInterface
wrappingKey?: SNRootKey
newEmail?: string
}): Promise<SessionManagerResponse> {
const userUuid = this.user!.uuid
const response = await this.apiService.changeCredentials({
const userUuid = this.getSureUser().uuid
const rawResponse = await this.apiService.changeCredentials({
userUuid,
currentServerPassword: parameters.currentServerPassword,
newServerPassword: parameters.newRootKey.serverPassword!,
newServerPassword: parameters.newRootKey.serverPassword as string,
newKeyParams: parameters.newRootKey.keyParams,
newEmail: parameters.newEmail,
})
return this.processChangeCredentialsResponse(response, parameters.newRootKey, parameters.wrappingKey)
let oldKeyPair: PkcKeyPair | undefined
let oldSigningKeyPair: PkcKeyPair | undefined
try {
oldKeyPair = this.protocolService.getKeyPair()
oldSigningKeyPair = this.protocolService.getSigningKeyPair()
} catch (error) {
void error
}
const processedResponse = await this.processChangeCredentialsResponse(
rawResponse,
parameters.newRootKey,
parameters.wrappingKey,
)
if (!isErrorResponse(rawResponse)) {
if (InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) {
const eventData: UserKeyPairChangedEventData = {
oldKeyPair,
oldSigningKeyPair,
newKeyPair: parameters.newRootKey.encryptionKeyPair,
newSigningKeyPair: parameters.newRootKey.signingKeyPair,
}
void this.notifyEvent(SessionEvent.UserKeyPairChanged, eventData)
}
}
return processedResponse
}
public async getSessionsList(): Promise<HttpResponse<SessionListEntry[]>> {
@@ -669,12 +737,10 @@ export class SNSessionManager
) {
await this.protocolService.setRootKey(rootKey, wrappingKey)
this.setUser(user)
this.memoizeUser(user)
this.diskStorageService.setValue(StorageKey.User, user)
void this.apiService.setHost(host)
this.httpService.setHost(host)
this.setSession(session)
@@ -777,16 +843,4 @@ export class SNSessionManager
return Result.ok(sessionOrError.getValue())
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
session: {
isSessionRenewChallengePresented: this.isSessionRenewChallengePresented,
online: this.online(),
offline: this.offline(),
isSignedIn: this.isSignedIn(),
isSignedIntoFirstPartyServer: this.isSignedIntoFirstPartyServer(),
},
})
}
}

View File

@@ -10,10 +10,17 @@ import {
PayloadEmitSource,
PayloadTimestampDefaults,
getIncrementedDirtyIndex,
Predicate,
} from '@standardnotes/models'
import { arrayByRemovingFromIndex, extendArray, UuidGenerator } from '@standardnotes/utils'
import { SNSyncService } from '../Sync/SyncService'
import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standardnotes/services'
import {
AbstractService,
InternalEventBusInterface,
MutatorClientInterface,
SingletonManagerInterface,
SyncEvent,
} from '@standardnotes/services'
/**
* The singleton manager allow consumers to ensure that only 1 item exists of a certain
@@ -26,7 +33,7 @@ import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standard
* 2. Items can override isSingleton, singletonPredicate, and strategyWhenConflictingWithItem (optional)
* to automatically gain singleton resolution.
*/
export class SNSingletonManager extends AbstractService {
export class SNSingletonManager extends AbstractService implements SingletonManagerInterface {
private resolveQueue: DecryptedItemInterface[] = []
private removeItemObserver!: () => void
@@ -34,6 +41,7 @@ export class SNSingletonManager extends AbstractService {
constructor(
private itemManager: ItemManager,
private mutator: MutatorClientInterface,
private payloadManager: PayloadManager,
private syncService: SNSyncService,
protected override internalEventBus: InternalEventBusInterface,
@@ -44,6 +52,7 @@ export class SNSingletonManager extends AbstractService {
public override deinit(): void {
;(this.syncService as unknown) = undefined
;(this.mutator as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.payloadManager as unknown) = undefined
@@ -148,7 +157,7 @@ export class SNSingletonManager extends AbstractService {
})
const deleteItems = arrayByRemovingFromIndex(earliestFirst, 0)
await this.itemManager.setItemsToBeDeleted(deleteItems)
await this.mutator.setItemsToBeDeleted(deleteItems)
}
public findSingleton<T extends DecryptedItemInterface>(
@@ -222,7 +231,66 @@ export class SNSingletonManager extends AbstractService {
...PayloadTimestampDefaults(),
})
const item = await this.itemManager.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted)
const item = await this.mutator.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted)
void this.syncService.sync({ sourceDescription: 'After find or create singleton' })
return item as T
}
public async findOrCreateSingleton<
C extends ItemContent = ItemContent,
T extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(predicate: Predicate<T>, contentType: ContentType, createContent: ItemContent): Promise<T> {
const existingItems = this.itemManager.itemsMatchingPredicate<T>(contentType, predicate)
if (existingItems.length > 0) {
return existingItems[0]
}
/** Item not found, safe to create after full sync has completed */
if (!this.syncService.getLastSyncDate()) {
/**
* Add a temporary observer in case of long-running sync request, where
* the item we're looking for ends up resolving early or in the middle.
*/
let matchingItem: DecryptedItemInterface | undefined
const removeObserver = this.itemManager.addObserver(contentType, ({ inserted }) => {
if (inserted.length > 0) {
const matchingItems = inserted.filter((i) => i.satisfiesPredicate(predicate))
if (matchingItems.length > 0) {
matchingItem = matchingItems[0]
}
}
})
await this.syncService.sync({ sourceDescription: 'Find or create singleton, before any sync has completed' })
removeObserver()
if (matchingItem) {
return matchingItem as T
}
/** Check again */
const refreshedItems = this.itemManager.itemsMatchingPredicate<T>(contentType, predicate)
if (refreshedItems.length > 0) {
return refreshedItems[0] as T
}
}
/** Safe to create */
const dirtyPayload = new DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content_type: contentType,
content: createContent,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
...PayloadTimestampDefaults(),
})
const item = await this.mutator.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted)
void this.syncService.sync({ sourceDescription: 'After find or create singleton' })

View File

@@ -1,10 +1,9 @@
import { ContentType } from '@standardnotes/common'
import { Copy, extendArray, UuidGenerator } from '@standardnotes/utils'
import { Copy, extendArray, UuidGenerator, Uuids } from '@standardnotes/utils'
import { SNLog } from '../../Log'
import { isErrorDecryptingParameters, SNRootKey } from '@standardnotes/encryption'
import * as Encryption from '@standardnotes/encryption'
import * as Services from '@standardnotes/services'
import { DiagnosticInfo } from '@standardnotes/services'
import {
CreateDecryptedLocalStorageContextPayload,
CreateDeletedLocalStorageContextPayload,
@@ -252,7 +251,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv
return rawContent as Services.StorageValuesObject
}
public setValue(key: string, value: unknown, mode = Services.StorageValueModes.Default): void {
public setValue<T>(key: string, value: T, mode = Services.StorageValueModes.Default): void {
this.setValueWithNoPersist(key, value, mode)
void this.persistValuesToDisk()
@@ -292,6 +291,14 @@ export class DiskStorageService extends Services.AbstractService implements Serv
return value != undefined ? (value as T) : (defaultValue as T)
}
public getAllKeys(mode = Services.StorageValueModes.Default): string[] {
if (!this.values) {
throw Error('Attempting to get all keys before loading local storage.')
}
return Object.keys(this.values[this.domainKeyForMode(mode)])
}
public async removeValue(key: string, mode = Services.StorageValueModes.Default): Promise<void> {
if (!this.values) {
throw Error(`Attempting to remove storage key ${key} before loading local storage.`)
@@ -370,20 +377,28 @@ export class DiskStorageService extends Services.AbstractService implements Serv
const encryptable: DecryptedPayloadInterface[] = []
const unencryptable: DecryptedPayloadInterface[] = []
const split = Encryption.SplitPayloadsByEncryptionType(decrypted)
if (split.itemsKeyEncryption) {
extendArray(encryptable, split.itemsKeyEncryption)
const { rootKeyEncryption, keySystemRootKeyEncryption, itemsKeyEncryption } =
Encryption.SplitPayloadsByEncryptionType(decrypted)
if (itemsKeyEncryption) {
extendArray(encryptable, itemsKeyEncryption)
}
if (split.rootKeyEncryption) {
if (keySystemRootKeyEncryption) {
extendArray(encryptable, keySystemRootKeyEncryption)
}
if (rootKeyEncryption) {
if (!rootKeyEncryptionAvailable) {
extendArray(unencryptable, split.rootKeyEncryption)
extendArray(unencryptable, rootKeyEncryption)
} else {
extendArray(encryptable, split.rootKeyEncryption)
extendArray(encryptable, rootKeyEncryption)
}
}
await this.deletePayloads(discardable)
if (discardable.length > 0) {
await this.deletePayloads(discardable)
}
const encryptableSplit = Encryption.SplitPayloadsByEncryptionType(encryptable)
@@ -406,16 +421,18 @@ export class DiskStorageService extends Services.AbstractService implements Serv
}
public async deletePayloads(payloads: DeletedPayloadInterface[]) {
await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid)))
await this.deletePayloadsWithUuids(Uuids(payloads))
}
public async forceDeletePayloads(payloads: FullyFormedPayloadInterface[]) {
await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid)))
public async deletePayloadsWithUuids(uuids: string[]): Promise<void> {
await this.executeCriticalFunction(async () => {
await Promise.all(uuids.map((uuid) => this.deviceInterface.removeDatabaseEntry(uuid, this.identifier)))
})
}
public async deletePayloadWithId(uuid: string) {
public async deletePayloadWithUuid(uuid: string) {
return this.executeCriticalFunction(async () => {
return this.deviceInterface.removeDatabaseEntry(uuid, this.identifier)
await this.deviceInterface.removeDatabaseEntry(uuid, this.identifier)
})
}
@@ -437,17 +454,4 @@ export class DiskStorageService extends Services.AbstractService implements Serv
await this.deviceInterface.removeRawStorageValue(this.getPersistenceKey())
})
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return {
storage: {
storagePersistable: this.storagePersistable,
persistencePolicy: Services.StoragePersistencePolicies[this.persistencePolicy],
needsPersist: this.needsPersist,
currentPersistPromise: this.currentPersistPromise != undefined,
isStorageWrapped: this.isStorageWrapped(),
allRawPayloadsCount: (await this.getAllRawPayloads()).length,
},
}
}
}

View File

@@ -21,17 +21,15 @@ export class AccountSyncOperation {
* @param receiver A function that receives callback multiple times during the operation
*/
constructor(
private payloads: ServerSyncPushContextualPayload[],
public readonly payloads: ServerSyncPushContextualPayload[],
private receiver: ResponseSignalReceiver<ServerSyncResponse>,
private lastSyncToken: string,
private paginationToken: string,
private apiService: SNApiService,
public readonly options: {
syncToken?: string
paginationToken?: string
sharedVaultUuids?: string[]
},
) {
this.payloads = payloads
this.lastSyncToken = lastSyncToken
this.paginationToken = paginationToken
this.apiService = apiService
this.receiver = receiver
this.pendingPayloads = payloads.slice()
}
@@ -55,13 +53,19 @@ export class AccountSyncOperation {
})
const payloads = this.popPayloads(this.upLimit)
const rawResponse = await this.apiService.sync(payloads, this.lastSyncToken, this.paginationToken, this.downLimit)
const rawResponse = await this.apiService.sync(
payloads,
this.options.syncToken,
this.options.paginationToken,
this.downLimit,
this.options.sharedVaultUuids,
)
const response = new ServerSyncResponse(rawResponse)
this.responses.push(response)
this.lastSyncToken = response.lastSyncToken as string
this.paginationToken = response.paginationToken as string
this.options.syncToken = response.lastSyncToken as string
this.options.paginationToken = response.paginationToken as string
try {
await this.receiver(SyncSignal.Response, response)
@@ -75,7 +79,7 @@ export class AccountSyncOperation {
}
get done() {
return this.pendingPayloads.length === 0 && !this.paginationToken
return this.pendingPayloads.length === 0 && !this.options.paginationToken
}
private get pendingUploadCount() {

View File

@@ -1,27 +1,36 @@
import {
ApiEndpointParam,
ConflictParams,
ConflictType,
SharedVaultInviteServerHash,
SharedVaultServerHash,
HttpError,
HttpResponse,
isErrorResponse,
RawSyncResponse,
ServerItemResponse,
UserEventServerHash,
AsymmetricMessageServerHash,
} from '@standardnotes/responses'
import {
FilterDisallowedRemotePayloadsAndMap,
CreateServerSyncSavedPayload,
ServerSyncSavedContextualPayload,
FilteredServerItem,
TrustedConflictParams,
} from '@standardnotes/models'
import { deepFreeze } from '@standardnotes/utils'
import { TrustedServerConflictMap } from './ServerConflictMap'
export class ServerSyncResponse {
public readonly savedPayloads: ServerSyncSavedContextualPayload[]
public readonly retrievedPayloads: FilteredServerItem[]
public readonly uuidConflictPayloads: FilteredServerItem[]
public readonly dataConflictPayloads: FilteredServerItem[]
public readonly rejectedPayloads: FilteredServerItem[]
readonly savedPayloads: ServerSyncSavedContextualPayload[]
readonly retrievedPayloads: FilteredServerItem[]
readonly conflicts: TrustedServerConflictMap
readonly asymmetricMessages: AsymmetricMessageServerHash[]
readonly vaults: SharedVaultServerHash[]
readonly vaultInvites: SharedVaultInviteServerHash[]
readonly userEvents: UserEventServerHash[]
private readonly rawConflictObjects: ConflictParams[]
private successResponseData: RawSyncResponse | undefined
@@ -32,6 +41,10 @@ export class ServerSyncResponse {
this.successResponseData = rawResponse.data
}
const conflicts = this.successResponseData?.conflicts || []
const legacyConflicts = this.successResponseData?.unsaved || []
this.rawConflictObjects = conflicts.concat(legacyConflicts)
this.savedPayloads = FilterDisallowedRemotePayloadsAndMap(this.successResponseData?.saved_items || []).map(
(rawItem) => {
return CreateServerSyncSavedPayload(rawItem)
@@ -40,15 +53,53 @@ export class ServerSyncResponse {
this.retrievedPayloads = FilterDisallowedRemotePayloadsAndMap(this.successResponseData?.retrieved_items || [])
this.dataConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawDataConflictItems)
this.conflicts = this.filterConflicts()
this.uuidConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawUuidConflictItems)
this.vaults = this.successResponseData?.shared_vaults || []
this.rejectedPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawRejectedPayloads)
this.vaultInvites = this.successResponseData?.shared_vault_invites || []
this.asymmetricMessages = this.successResponseData?.asymmetric_messages || []
this.userEvents = this.successResponseData?.user_events || []
deepFreeze(this)
}
private filterConflicts(): TrustedServerConflictMap {
const conflicts = this.rawConflictObjects
const trustedConflicts: TrustedServerConflictMap = {}
for (const conflict of conflicts) {
let serverItem: FilteredServerItem | undefined
let unsavedItem: FilteredServerItem | undefined
if (conflict.unsaved_item) {
unsavedItem = FilterDisallowedRemotePayloadsAndMap([conflict.unsaved_item])[0]
}
if (conflict.server_item) {
serverItem = FilterDisallowedRemotePayloadsAndMap([conflict.server_item])[0]
}
if (!trustedConflicts[conflict.type]) {
trustedConflicts[conflict.type] = []
}
const conflictArray = trustedConflicts[conflict.type]
if (conflictArray) {
const entry: TrustedConflictParams = <TrustedConflictParams>{
type: conflict.type,
server_item: serverItem,
unsaved_item: unsavedItem,
}
conflictArray.push(entry)
}
}
return trustedConflicts
}
public get error(): HttpError | undefined {
return isErrorResponse(this.rawResponse) ? this.rawResponse.data?.error : undefined
}
@@ -66,56 +117,9 @@ export class ServerSyncResponse {
}
public get numberOfItemsInvolved(): number {
return this.allFullyFormedPayloads.length
}
const allPayloads = [...this.retrievedPayloads, ...this.rawConflictObjects]
private get allFullyFormedPayloads(): FilteredServerItem[] {
return [
...this.retrievedPayloads,
...this.dataConflictPayloads,
...this.uuidConflictPayloads,
...this.rejectedPayloads,
]
}
private get rawUuidConflictItems(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return conflict.type === ConflictType.UuidConflict
})
.map((conflict) => {
return conflict.unsaved_item || conflict.item!
})
}
private get rawDataConflictItems(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return conflict.type === ConflictType.ConflictingData
})
.map((conflict) => {
return conflict.server_item || conflict.item!
})
}
private get rawRejectedPayloads(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return (
conflict.type === ConflictType.ContentTypeError ||
conflict.type === ConflictType.ContentError ||
conflict.type === ConflictType.ReadOnlyError
)
})
.map((conflict) => {
return conflict.unsaved_item!
})
}
private get rawConflictObjects(): ConflictParams[] {
const conflicts = this.successResponseData?.conflicts || []
const legacyConflicts = this.successResponseData?.unsaved || []
return conflicts.concat(legacyConflicts)
return allPayloads.length
}
public get hasError(): boolean {

View File

@@ -1,3 +1,4 @@
import { ConflictParams, ConflictType } from '@standardnotes/responses'
import {
ImmutablePayloadCollection,
HistoryMap,
@@ -11,13 +12,12 @@ import {
DeltaRemoteRejected,
DeltaEmit,
} from '@standardnotes/models'
import { DecryptedServerConflictMap } from './ServerConflictMap'
type PayloadSet = {
retrievedPayloads: FullyFormedPayloadInterface[]
savedPayloads: ServerSyncSavedContextualPayload[]
uuidConflictPayloads: FullyFormedPayloadInterface[]
dataConflictPayloads: FullyFormedPayloadInterface[]
rejectedPayloads: FullyFormedPayloadInterface[]
conflicts: DecryptedServerConflictMap
}
/**
@@ -39,8 +39,8 @@ export class ServerSyncResponseResolver {
emits.push(this.processRetrievedPayloads())
emits.push(this.processSavedPayloads())
emits.push(this.processUuidConflictPayloads())
emits.push(this.processDataConflictPayloads())
emits.push(this.processUuidConflictUnsavedPayloads())
emits.push(this.processDataConflictServerPayloads())
emits.push(this.processRejectedPayloads())
return emits
@@ -60,27 +60,42 @@ export class ServerSyncResponseResolver {
return delta.result()
}
private processDataConflictPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.dataConflictPayloads)
private getConflictsForType<T extends ConflictParams<FullyFormedPayloadInterface>>(type: ConflictType): T[] {
const results = this.payloadSet.conflicts[type] || []
const delta = new DeltaRemoteDataConflicts(this.baseCollection, collection, this.historyMap)
return results as T[]
}
private processDataConflictServerPayloads(): DeltaEmit {
const delta = new DeltaRemoteDataConflicts(
this.baseCollection,
this.getConflictsForType(ConflictType.ConflictingData),
this.historyMap,
)
return delta.result()
}
private processUuidConflictPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.uuidConflictPayloads)
const delta = new DeltaRemoteUuidConflicts(this.baseCollection, collection)
private processUuidConflictUnsavedPayloads(): DeltaEmit {
const delta = new DeltaRemoteUuidConflicts(this.baseCollection, this.getConflictsForType(ConflictType.UuidConflict))
return delta.result()
}
private processRejectedPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.rejectedPayloads)
const conflicts = [
...this.getConflictsForType(ConflictType.ContentTypeError),
...this.getConflictsForType(ConflictType.ContentError),
...this.getConflictsForType(ConflictType.ReadOnlyError),
...this.getConflictsForType(ConflictType.UuidError),
...this.getConflictsForType(ConflictType.SharedVaultSnjsVersionError),
...this.getConflictsForType(ConflictType.SharedVaultInsufficientPermissionsError),
...this.getConflictsForType(ConflictType.SharedVaultNotMemberError),
...this.getConflictsForType(ConflictType.SharedVaultInvalidState),
]
const delta = new DeltaRemoteRejected(this.baseCollection, collection)
return delta.result()
const delta = new DeltaRemoteRejected(this.baseCollection, conflicts)
const result = delta.result()
return result
}
}

View File

@@ -0,0 +1,5 @@
import { ConflictType, ConflictParams } from '@standardnotes/responses'
import { FullyFormedPayloadInterface, TrustedConflictParams } from '@standardnotes/models'
export type TrustedServerConflictMap = Partial<Record<ConflictType, TrustedConflictParams[]>>
export type DecryptedServerConflictMap = Partial<Record<ConflictType, ConflictParams<FullyFormedPayloadInterface>[]>>

View File

@@ -1,15 +1,15 @@
import { ConflictParams, ConflictType } from '@standardnotes/responses'
import { log, LoggingDomain } from './../../Logging'
import { AccountSyncOperation } from '@Lib/Services/Sync/Account/Operation'
import { ContentType } from '@standardnotes/common'
import {
Uuids,
extendArray,
isNotUndefined,
isNullOrUndefined,
removeFromIndex,
sleep,
subtractFromArray,
useBoolean,
Uuids,
} from '@standardnotes/utils'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { OfflineSyncOperation } from '@Lib/Services/Sync/Offline/Operation'
@@ -56,6 +56,12 @@ import {
getIncrementedDirtyIndex,
getCurrentDirtyIndex,
ItemContent,
KeySystemItemsKeyContent,
KeySystemItemsKeyInterface,
FullyFormedTransferPayload,
ItemMutator,
isDecryptedOrDeletedItem,
MutationType,
} from '@standardnotes/models'
import {
AbstractService,
@@ -71,11 +77,14 @@ import {
SyncOptions,
SyncQueueStrategy,
SyncServiceInterface,
DiagnosticInfo,
EncryptionService,
DeviceInterface,
isFullEntryLoadChunkResponse,
isChunkFullEntry,
SyncEventReceivedSharedVaultInvitesData,
SyncEventReceivedRemoteSharedVaultsData,
SyncEventReceivedUserEventsData,
SyncEventReceivedAsymmetricMessagesData,
} from '@standardnotes/services'
import { OfflineSyncResponse } from './Offline/Response'
import {
@@ -86,10 +95,23 @@ import {
} from '@standardnotes/encryption'
import { CreatePayloadFromRawServerItem } from './Account/Utilities'
import { ApplicationSyncOptions } from '@Lib/Application/Options/OptionalOptions'
import { DecryptedServerConflictMap, TrustedServerConflictMap } from './Account/ServerConflictMap'
const DEFAULT_MAJOR_CHANGE_THRESHOLD = 15
const INVALID_SESSION_RESPONSE_STATUS = 401
/** Content types appearing first are always mapped first */
const ContentTypeLocalLoadPriorty = [
ContentType.ItemsKey,
ContentType.KeySystemRootKey,
ContentType.KeySystemItemsKey,
ContentType.VaultListing,
ContentType.TrustedContact,
ContentType.UserPrefs,
ContentType.Component,
ContentType.Theme,
]
/**
* The sync service orchestrates with the model manager, api service, and storage service
* to ensure consistent state between the three. When a change is made to an item, consumers
@@ -100,7 +122,7 @@ const INVALID_SESSION_RESPONSE_STATUS = 401
* The sync service largely does not perform any task unless it is called upon.
*/
export class SNSyncService
extends AbstractService<SyncEvent, ServerSyncResponse | OfflineSyncResponse | { source: SyncSource }>
extends AbstractService<SyncEvent>
implements SyncServiceInterface, InternalEventHandlerInterface, SyncClientInterface
{
private dirtyIndexAtLastPresyncSave?: number
@@ -128,14 +150,6 @@ export class SNSyncService
public lastSyncInvokationPromise?: Promise<unknown>
public currentSyncRequestPromise?: Promise<void>
/** Content types appearing first are always mapped first */
private readonly localLoadPriorty = [
ContentType.ItemsKey,
ContentType.UserPrefs,
ContentType.Component,
ContentType.Theme,
]
constructor(
private itemManager: ItemManager,
private sessionManager: SNSessionManager,
@@ -225,29 +239,21 @@ export class SNSyncService
return this.databaseLoaded
}
private async processItemsKeysFirstDuringDatabaseLoad(
itemsKeysPayloads: FullyFormedPayloadInterface[],
): Promise<void> {
if (itemsKeysPayloads.length === 0) {
private async processPriorityItemsForDatabaseLoad(items: FullyFormedPayloadInterface[]): Promise<void> {
if (items.length === 0) {
return
}
const encryptedItemsKeysPayloads = itemsKeysPayloads.filter(isEncryptedPayload)
const encryptedPayloads = items.filter(isEncryptedPayload)
const alreadyDecryptedPayloads = items.filter(isDecryptedPayload) as DecryptedPayloadInterface<ItemsKeyContent>[]
const originallyDecryptedItemsKeysPayloads = itemsKeysPayloads.filter(
isDecryptedPayload,
) as DecryptedPayloadInterface<ItemsKeyContent>[]
const encryptionSplit = SplitPayloadsByEncryptionType(encryptedPayloads)
const decryptionSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit)
const itemsKeysSplit: KeyedDecryptionSplit = {
usesRootKeyWithKeyLookup: {
items: encryptedItemsKeysPayloads,
},
}
const newlyDecryptedItemsKeys = await this.protocolService.decryptSplit(itemsKeysSplit)
const newlyDecryptedPayloads = await this.protocolService.decryptSplit(decryptionSplit)
await this.payloadManager.emitPayloads(
[...originallyDecryptedItemsKeysPayloads, ...newlyDecryptedItemsKeys],
[...alreadyDecryptedPayloads, ...newlyDecryptedPayloads],
PayloadEmitSource.LocalDatabaseLoaded,
)
}
@@ -262,7 +268,7 @@ export class SNSyncService
const chunks = await this.device.getDatabaseLoadChunks(
{
batchSize: this.options.loadBatchSize,
contentTypePriority: this.localLoadPriorty,
contentTypePriority: ContentTypeLocalLoadPriorty,
uuidPriority: this.launchPriorityUuids,
},
this.identifier,
@@ -272,18 +278,30 @@ export class SNSyncService
? chunks.fullEntries.itemsKeys.entries
: await this.device.getDatabaseEntries(this.identifier, chunks.keys.itemsKeys.keys)
const itemsKeyPayloads = itemsKeyEntries
.map((entry) => {
try {
return CreatePayload(entry, PayloadSource.Constructor)
} catch (e) {
console.error('Creating payload failed', e)
return undefined
}
})
.filter(isNotUndefined)
const keySystemRootKeyEntries = isFullEntryLoadChunkResponse(chunks)
? chunks.fullEntries.keySystemRootKeys.entries
: await this.device.getDatabaseEntries(this.identifier, chunks.keys.keySystemRootKeys.keys)
await this.processItemsKeysFirstDuringDatabaseLoad(itemsKeyPayloads)
const keySystemItemsKeyEntries = isFullEntryLoadChunkResponse(chunks)
? chunks.fullEntries.keySystemItemsKeys.entries
: await this.device.getDatabaseEntries(this.identifier, chunks.keys.keySystemItemsKeys.keys)
const createPayloadFromEntry = (entry: FullyFormedTransferPayload) => {
try {
return CreatePayload(entry, PayloadSource.LocalDatabaseLoaded)
} catch (e) {
console.error('Creating payload failed', e)
return undefined
}
}
await this.processPriorityItemsForDatabaseLoad(itemsKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined))
await this.processPriorityItemsForDatabaseLoad(
keySystemRootKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined),
)
await this.processPriorityItemsForDatabaseLoad(
keySystemItemsKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined),
)
/**
* Map in batches to give interface a chance to update. Note that total decryption
@@ -308,7 +326,7 @@ export class SNSyncService
const payloads = dbEntries
.map((entry) => {
try {
return CreatePayload(entry, PayloadSource.Constructor)
return CreatePayload(entry, PayloadSource.LocalDatabaseLoaded)
} catch (e) {
console.error('Creating payload failed', e)
return undefined
@@ -348,13 +366,10 @@ export class SNSyncService
}
}
const split: KeyedDecryptionSplit = {
usesItemsKeyWithKeyLookup: {
items: encrypted,
},
}
const encryptionSplit = SplitPayloadsByEncryptionType(encrypted)
const decryptionSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit)
const results = await this.protocolService.decryptSplit(split)
const results = await this.protocolService.decryptSplit(decryptionSplit)
await this.payloadManager.emitPayloads([...nonencrypted, ...results], PayloadEmitSource.LocalDatabaseLoaded)
@@ -616,11 +631,7 @@ export class SNSyncService
if (useStrategy === SyncQueueStrategy.ResolveOnNext) {
return this.queueStrategyResolveOnNext()
} else if (useStrategy === SyncQueueStrategy.ForceSpawnNew) {
return this.queueStrategyForceSpawnNew({
mode: options.mode,
checkIntegrity: options.checkIntegrity,
source: options.source,
})
return this.queueStrategyForceSpawnNew(options)
} else {
throw Error(`Unhandled timing strategy ${useStrategy}`)
}
@@ -634,7 +645,7 @@ export class SNSyncService
) {
this.opStatus.setDidBegin()
await this.notifyEvent(SyncEvent.SyncWillBegin)
await this.notifyEvent(SyncEvent.SyncDidBeginProcessing)
/**
* Subtract from array as soon as we're sure they'll be called.
@@ -647,12 +658,41 @@ export class SNSyncService
* Setting this value means the item was 100% sent to the server.
*/
if (items.length > 0) {
return this.itemManager.setLastSyncBeganForItems(items, beginDate, frozenDirtyIndex)
return this.setLastSyncBeganForItems(items, beginDate, frozenDirtyIndex)
} else {
return items
}
}
private async setLastSyncBeganForItems(
itemsToLookupUuidsFor: (DecryptedItemInterface | DeletedItemInterface)[],
date: Date,
globalDirtyIndex: number,
): Promise<(DecryptedItemInterface | DeletedItemInterface)[]> {
const uuids = Uuids(itemsToLookupUuidsFor)
const items = this.itemManager.getCollection().findAll(uuids).filter(isDecryptedOrDeletedItem)
const payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[] = []
for (const item of items) {
const mutator = new ItemMutator<DecryptedPayloadInterface | DeletedPayloadInterface>(
item,
MutationType.NonDirtying,
)
mutator.setBeginSync(date, globalDirtyIndex)
const payload = mutator.getResult()
payloads.push(payload)
}
await this.payloadManager.emitPayloads(payloads, PayloadEmitSource.PreSyncSave)
return this.itemManager.findAnyItems(uuids) as (DecryptedItemInterface | DeletedItemInterface)[]
}
/**
* The InTime resolve queue refers to any sync requests that were made while we still
* have not sent out the current request. So, anything in the InTime resolve queue
@@ -725,12 +765,15 @@ export class SNSyncService
private async createServerSyncOperation(
payloads: ServerSyncPushContextualPayload[],
checkIntegrity: boolean,
source: SyncSource,
options: SyncOptions,
mode: SyncMode = SyncMode.Default,
) {
const syncToken = await this.getLastSyncToken()
const paginationToken = await this.getPaginationToken()
const syncToken =
options.sharedVaultUuids && options.sharedVaultUuids.length > 0 && options.syncSharedVaultsFromScratch
? undefined
: await this.getLastSyncToken()
const paginationToken =
options.sharedVaultUuids && options.syncSharedVaultsFromScratch ? undefined : await this.getPaginationToken()
const operation = new AccountSyncOperation(
payloads,
@@ -753,20 +796,23 @@ export class SNSyncService
break
}
},
syncToken,
paginationToken,
this.apiService,
{
syncToken,
paginationToken,
sharedVaultUuids: options.sharedVaultUuids,
},
)
log(
LoggingDomain.Sync,
'Syncing online user',
'source',
SyncSource[source],
SyncSource[options.source],
'operation id',
operation.id,
'integrity check',
checkIntegrity,
options.checkIntegrity,
'mode',
SyncMode[mode],
'syncToken',
@@ -789,12 +835,7 @@ export class SNSyncService
const { uploadPayloads, syncMode } = await this.getOnlineSyncParameters(payloads, options.mode)
return {
operation: await this.createServerSyncOperation(
uploadPayloads,
useBoolean(options.checkIntegrity, false),
options.source,
syncMode,
),
operation: await this.createServerSyncOperation(uploadPayloads, options, syncMode),
mode: syncMode,
}
} else {
@@ -867,6 +908,7 @@ export class SNSyncService
await this.notifyEventSync(SyncEvent.SyncCompletedWithAllItemsUploadedAndDownloaded, {
source: options.source,
options,
})
this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue)
@@ -889,7 +931,7 @@ export class SNSyncService
this.opStatus.clearError()
await this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response)
await this.notifyEvent(SyncEvent.PaginatedSyncRequestCompleted, response)
}
private handleErrorServerResponse(response: ServerSyncResponse) {
@@ -917,19 +959,36 @@ export class SNSyncService
const historyMap = this.historyService.getHistoryMapCopy()
if (response.userEvents) {
await this.notifyEventSync(SyncEvent.ReceivedUserEvents, response.userEvents as SyncEventReceivedUserEventsData)
}
if (response.asymmetricMessages) {
await this.notifyEventSync(
SyncEvent.ReceivedAsymmetricMessages,
response.asymmetricMessages as SyncEventReceivedAsymmetricMessagesData,
)
}
if (response.vaults) {
await this.notifyEventSync(
SyncEvent.ReceivedRemoteSharedVaults,
response.vaults as SyncEventReceivedRemoteSharedVaultsData,
)
}
if (response.vaultInvites) {
await this.notifyEventSync(
SyncEvent.ReceivedSharedVaultInvites,
response.vaultInvites as SyncEventReceivedSharedVaultInvitesData,
)
}
const resolver = new ServerSyncResponseResolver(
{
retrievedPayloads: await this.processServerPayloads(response.retrievedPayloads, PayloadSource.RemoteRetrieved),
savedPayloads: response.savedPayloads,
uuidConflictPayloads: await this.processServerPayloads(
response.uuidConflictPayloads,
PayloadSource.RemoteRetrieved,
),
dataConflictPayloads: await this.processServerPayloads(
response.dataConflictPayloads,
PayloadSource.RemoteRetrieved,
),
rejectedPayloads: await this.processServerPayloads(response.rejectedPayloads, PayloadSource.RemoteRetrieved),
conflicts: await this.decryptServerConflicts(response.conflicts),
},
masterCollection,
operation.payloadsSavedOrSaving,
@@ -954,11 +1013,69 @@ export class SNSyncService
await this.persistPayloads(payloadsToPersist)
}
await Promise.all([
this.setLastSyncToken(response.lastSyncToken as string),
this.setPaginationToken(response.paginationToken as string),
this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response),
])
if (!operation.options.sharedVaultUuids) {
await Promise.all([
this.setLastSyncToken(response.lastSyncToken as string),
this.setPaginationToken(response.paginationToken as string),
])
}
await this.notifyEvent(SyncEvent.PaginatedSyncRequestCompleted, {
...response,
uploadedPayloads: operation.payloads,
options: operation.options,
})
}
private async decryptServerConflicts(conflictMap: TrustedServerConflictMap): Promise<DecryptedServerConflictMap> {
const decrypted: DecryptedServerConflictMap = {}
for (const conflictType of Object.keys(conflictMap)) {
const conflictsForType = conflictMap[conflictType as ConflictType]
if (!conflictsForType) {
continue
}
if (!decrypted[conflictType as ConflictType]) {
decrypted[conflictType as ConflictType] = []
}
const decryptedConflictsForType = decrypted[conflictType as ConflictType]
if (!decryptedConflictsForType) {
throw Error('Decrypted conflicts for type should exist')
}
for (const conflict of conflictsForType) {
const decryptedUnsavedItem = conflict.unsaved_item
? await this.processServerPayload(conflict.unsaved_item, PayloadSource.RemoteRetrieved)
: undefined
const decryptedServerItem = conflict.server_item
? await this.processServerPayload(conflict.server_item, PayloadSource.RemoteRetrieved)
: undefined
const decryptedEntry: ConflictParams<FullyFormedPayloadInterface> = <
ConflictParams<FullyFormedPayloadInterface>
>{
type: conflict.type,
unsaved_item: decryptedUnsavedItem,
server_item: decryptedServerItem,
}
decryptedConflictsForType.push(decryptedEntry)
}
}
return decrypted
}
private async processServerPayload(
item: FilteredServerItem,
source: PayloadSource,
): Promise<FullyFormedPayloadInterface> {
const result = await this.processServerPayloads([item], source)
return result[0]
}
private async processServerPayloads(
@@ -971,7 +1088,8 @@ export class SNSyncService
const results: FullyFormedPayloadInterface[] = [...deleted]
const { rootKeyEncryption, itemsKeyEncryption } = SplitPayloadsByEncryptionType(encrypted)
const { rootKeyEncryption, itemsKeyEncryption, keySystemRootKeyEncryption } =
SplitPayloadsByEncryptionType(encrypted)
const { results: rootKeyDecryptionResults, map: processedItemsKeys } = await this.decryptServerItemsKeys(
rootKeyEncryption || [],
@@ -979,8 +1097,16 @@ export class SNSyncService
extendArray(results, rootKeyDecryptionResults)
const { results: keySystemRootKeyDecryptionResults, map: processedKeySystemItemsKeys } =
await this.decryptServerKeySystemItemsKeys(keySystemRootKeyEncryption || [])
extendArray(results, keySystemRootKeyDecryptionResults)
if (itemsKeyEncryption) {
const decryptionResults = await this.decryptProcessedServerPayloads(itemsKeyEncryption, processedItemsKeys)
const decryptionResults = await this.decryptProcessedServerPayloads(itemsKeyEncryption, {
...processedItemsKeys,
...processedKeySystemItemsKeys,
})
extendArray(results, decryptionResults)
}
@@ -1017,17 +1143,53 @@ export class SNSyncService
}
}
private async decryptServerKeySystemItemsKeys(payloads: EncryptedPayloadInterface[]) {
const map: Record<UuidString, DecryptedPayloadInterface<KeySystemItemsKeyContent>> = {}
if (payloads.length === 0) {
return {
results: [],
map,
}
}
const keySystemRootKeySplit: KeyedDecryptionSplit = {
usesKeySystemRootKeyWithKeyLookup: {
items: payloads,
},
}
const results = await this.protocolService.decryptSplit<KeySystemItemsKeyContent>(keySystemRootKeySplit)
results.forEach((result) => {
if (
isDecryptedPayload<KeySystemItemsKeyContent>(result) &&
result.content_type === ContentType.KeySystemItemsKey
) {
map[result.uuid] = result
}
})
return {
results,
map,
}
}
private async decryptProcessedServerPayloads(
payloads: EncryptedPayloadInterface[],
map: Record<UuidString, DecryptedPayloadInterface<ItemsKeyContent>>,
map: Record<UuidString, DecryptedPayloadInterface<ItemsKeyContent | KeySystemItemsKeyContent>>,
): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
return Promise.all(
payloads.map(async (encrypted) => {
const previouslyProcessedItemsKey: DecryptedPayloadInterface<ItemsKeyContent> | undefined =
map[encrypted.items_key_id as string]
const previouslyProcessedItemsKey:
| DecryptedPayloadInterface<ItemsKeyContent | KeySystemItemsKeyContent>
| undefined = map[encrypted.items_key_id as string]
const itemsKey = previouslyProcessedItemsKey
? (CreateDecryptedItemFromPayload(previouslyProcessedItemsKey) as ItemsKeyInterface)
? (CreateDecryptedItemFromPayload(previouslyProcessedItemsKey) as
| ItemsKeyInterface
| KeySystemItemsKeyInterface)
: undefined
const keyedSplit: KeyedDecryptionSplit = {}
@@ -1251,26 +1413,13 @@ export class SNSyncService
await this.persistPayloads(emit.emits)
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
const dirtyUuids = Uuids(this.itemsNeedingSync())
return {
sync: {
syncToken: await this.getLastSyncToken(),
cursorToken: await this.getPaginationToken(),
dirtyIndexAtLastPresyncSave: this.dirtyIndexAtLastPresyncSave,
lastSyncDate: this.lastSyncDate,
outOfSync: this.outOfSync,
completedOnlineDownloadFirstSync: this.completedOnlineDownloadFirstSync,
clientLocked: this.clientLocked,
databaseLoaded: this.databaseLoaded,
syncLock: this.syncLock,
dealloced: this.dealloced,
itemsNeedingSync: dirtyUuids,
itemsNeedingSyncCount: dirtyUuids.length,
pendingRequestCount: this.resolveQueue.length + this.spawnQueue.length,
},
}
async syncSharedVaultsFromScratch(sharedVaultUuids: string[]): Promise<void> {
await this.sync({
sharedVaultUuids: sharedVaultUuids,
syncSharedVaultsFromScratch: true,
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
awaitAll: true,
})
}
/** @e2e_testing */

View File

@@ -1,3 +0,0 @@
export const InputStrings = {
FileAccountPassword: 'File account password',
}

View File

@@ -1,9 +1,7 @@
import { ConfirmStrings } from './Confirm'
import { InputStrings } from './Input'
import { NetworkStrings } from './Network'
export const Strings = {
Network: NetworkStrings,
Confirm: ConfirmStrings,
Input: InputStrings,
}

View File

@@ -9,6 +9,7 @@ export * from './Types'
export * from './Version'
export * from '@standardnotes/common'
export * from '@standardnotes/domain-core'
export * from '@standardnotes/api'
export * from '@standardnotes/encryption'
export * from '@standardnotes/features'
export * from '@standardnotes/files'

View File

@@ -8,6 +8,8 @@
"emitDeclarationOnly": true,
"esModuleInterop": true,
"isolatedModules": true,
"lib": ["es6", "dom", "es2016", "es2017"],
"module": "esnext",
"moduleResolution": "node",
"newLine": "lf",
"noFallthroughCasesInSwitch": true,
@@ -17,11 +19,11 @@
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"outDir": "../dist/@types",
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"target": "esnext",
"target": "es6",
"paths": {
"@Lib/*": ["*"],
"@Services/*": ["Services/*"]