import { AbstractService, InternalEventBusInterface, MutatorClientInterface, ItemRelationshipDirection, AlertService, } from '@standardnotes/services' import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption' import { ContentType } from '@standardnotes/domain-core' import { ItemManager } from '../Items' import { PayloadManager } from '../Payloads/PayloadManager' import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders' import { ActionsExtensionMutator, AppDataField, ComponentInterface, ComponentMutator, CreateDecryptedMutatorForItem, DecryptedItemInterface, DecryptedItemMutator, DecryptedPayload, DecryptedPayloadInterface, DefaultAppDomain, DeleteItemMutator, EncryptedItemInterface, FeatureRepoMutator, FileItem, FileMutator, FillItemContent, ItemContent, ItemsKeyInterface, ItemsKeyMutatorInterface, MutationType, NoteMutator, PayloadEmitSource, PayloadsByDuplicating, PayloadTimestampDefaults, PayloadVaultOverrides, predicateFromDSLString, PredicateInterface, SmartView, SmartViewContent, SmartViewDefaultIconName, SNActionsExtension, 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 payloadManager: PayloadManager, private alerts: AlertService, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) } override deinit() { super.deinit() ;(this.itemManager as unknown) = undefined ;(this.payloadManager as unknown) = undefined } /** * 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 { const results = await this.changeItems( [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 DecryptedItemMutator = DecryptedItemMutator, I extends DecryptedItemInterface = DecryptedItemInterface, >( itemsToLookupUuidsFor: I[], mutate?: (mutator: M) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { 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)[]> { 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( transaction: TransactionalMutation, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { 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 changeNote( itemToLookupUuidFor: SNNote, mutate: (mutator: NoteMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { const note = this.itemManager.findItem(itemToLookupUuidFor.uuid) if (!note) { throw Error('Attempting to change non-existant note') } const mutator = new NoteMutator(note, mutationType) return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) } async changeTag( itemToLookupUuidFor: SNTag, mutate: (mutator: TagMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { const tag = this.itemManager.findItem(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(itemToLookupUuidFor.uuid) } async changeComponent( itemToLookupUuidFor: ComponentInterface, mutate: (mutator: ComponentMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { const component = this.itemManager.findItem(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(itemToLookupUuidFor.uuid) } async changeFeatureRepo( itemToLookupUuidFor: SNFeatureRepo, mutate: (mutator: FeatureRepoMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { 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(itemToLookupUuidFor.uuid) } async changeActionsExtension( itemToLookupUuidFor: SNActionsExtension, mutate: (mutator: ActionsExtensionMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { const extension = this.itemManager.findItem(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(itemToLookupUuidFor.uuid) } async changeItemsKey( itemToLookupUuidFor: ItemsKeyInterface, mutate: (mutator: ItemsKeyMutatorInterface) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { const itemsKey = this.itemManager.findItem(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.itemManager.findSureItem(itemToLookupUuidFor.uuid) } private async applyTransform( mutator: T, mutate: (mutator: T) => void, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { 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 { 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( itemToLookupUuidFor: T, isConflict = false, additionalContent?: Partial, ) { const item = this.itemManager.findSureItem(itemToLookupUuidFor.uuid) const payload = item.payload.copy() const resultingPayloads = PayloadsByDuplicating({ payload, baseCollection: this.payloadManager.getMasterCollection(), isConflict, additionalContent: { appData: { [DefaultAppDomain]: { [AppDataField.UserModifiedDate]: new Date(), }, }, ...additionalContent, }, }) await this.payloadManager.emitPayloads(resultingPayloads, PayloadEmitSource.LocalChanged) const duplicate = this.itemManager.findSureItem(resultingPayloads[0].uuid) return duplicate } public async createItem( contentType: string, content: C, needsSync = false, vault?: VaultListingInterface, ): Promise { const payload = new DecryptedPayload({ uuid: UuidGenerator.GenerateUuid(), content_type: contentType, content: FillItemContent(content), dirty: needsSync, ...PayloadVaultOverrides(vault), ...PayloadTimestampDefaults(), }) await this.payloadManager.emitPayload(payload, PayloadEmitSource.LocalInserted) return this.itemManager.findSureItem(payload.uuid) } public async insertItem(item: DecryptedItemInterface, setDirty = true): Promise { const existingItem = this.itemManager.findItem(item.uuid) if (existingItem) { throw Error('Attempting to insert item that already exists') } if (setDirty) { const mutator = CreateDecryptedMutatorForItem(item, MutationType.UpdateUserTimestamps) const dirtiedPayload = mutator.getResult() const insertedItem = await this.emitItemFromPayload(dirtiedPayload, PayloadEmitSource.LocalInserted) return insertedItem } else { return this.emitItemFromPayload(item.payload, PayloadEmitSource.LocalChanged) } } public async insertItems( items: DecryptedItemInterface[], emitSource: PayloadEmitSource = PayloadEmitSource.LocalInserted, ): Promise { return this.emitItemsFromPayloads( items.map((item) => item.payload), emitSource, ) } public async emitItemFromPayload( payload: DecryptedPayloadInterface, emitSource: PayloadEmitSource, ): Promise { await this.payloadManager.emitPayload(payload, emitSource) const result = this.itemManager.findSureItem(payload.uuid) if (!result) { throw Error("Emitted item can't be found") } return result } public async emitItemsFromPayloads( payloads: DecryptedPayloadInterface[], emitSource: PayloadEmitSource, ): Promise { 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 { 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 { await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item))) } public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise { 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 { const newTag = await this.createItem( ContentType.TYPES.Tag, FillItemContent({ title: dto.title }), true, dto.createInVault, ) if (dto.parentItemToLookupUuidFor) { const parentTag = this.itemManager.findItem(dto.parentItemToLookupUuidFor.uuid) if (!parentTag) { throw new Error('Invalid parent tag') } return this.changeTag(newTag, (m) => { m.makeChildOf(parentTag) }) } return newTag } public async createSmartView(dto: { title: string predicate: PredicateInterface iconString?: string vault?: VaultListingInterface }): Promise { return this.createItem( ContentType.TYPES.SmartView, FillItemContent({ title: dto.title, predicate: dto.predicate.toJson(), iconString: dto.iconString || SmartViewDefaultIconName, } as SmartViewContent), true, dto.vault, ) as Promise } public async createSmartViewFromDSL( dsl: string, vault?: VaultListingInterface, ): Promise { 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(dsl) return this.createSmartView({ title, predicate, vault }) } public async createTagOrSmartView( title: string, vault?: VaultListingInterface, ): Promise { if (this.itemManager.isSmartViewTitle(title)) { return this.createSmartViewFromDSL(title, vault) as Promise } else { return this.createTag({ title, createInVault: vault }) as Promise } } public async findOrCreateTagByTitle(dto: { title: string parentItemToLookupUuidFor?: SNTag createInVault?: VaultListingInterface }): Promise { const tag = this.itemManager.findTagByTitleAndParent(dto.title, dto.parentItemToLookupUuidFor) return tag || this.createTag(dto) } public renameFile(file: FileItem, name: string): Promise { return this.changeItem(file, (mutator) => { mutator.name = name }) } public async mergeItem(item: DecryptedItemInterface, source: PayloadEmitSource): Promise { return this.emitItemFromPayload(item.payloadRepresentation(), source) } public async setItemNeedsSync( item: DecryptedItemInterface, updateTimestamps = false, ): Promise { return this.setItemDirty(item, updateTimestamps) } public async setItemsNeedsSync(items: DecryptedItemInterface[]): Promise<(DecryptedItemInterface | undefined)[]> { return this.setItemsDirty(items) } public async deleteItem(item: DecryptedItemInterface | EncryptedItemInterface): Promise { return this.deleteItems([item]) } public async deleteItems(items: (DecryptedItemInterface | EncryptedItemInterface)[]): Promise { await this.setItemsToBeDeleted(items) } /** * Permanently deletes any items currently in the trash. Consumer must manually call sync. */ public async emptyTrash(): Promise { const notes = this.itemManager.trashedItems await this.setItemsToBeDeleted(notes) } public async migrateTagsToFolders(): Promise { await TagsToFoldersMigrationApplicator.run(this.itemManager, this) } public async findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise { return this.findOrCreateTagByTitle({ title, createInVault }) } /** * @returns The changed child tag */ public async setTagParent(parentTag: SNTag, childTag: SNTag): Promise { if (parentTag.uuid === childTag.uuid) { throw new Error('Can not set a tag parent of itself') } if (this.itemManager.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: SNTag): Promise { const parentTag = this.itemManager.getTagParent(childTag) if (!parentTag) { return Promise.resolve(childTag) } return this.changeTag(childTag, (m) => { m.unsetParent() }) } public async associateFileWithNote(file: FileItem, note: SNNote): Promise { 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(file, (mutator) => { mutator.addNote(note) }) } public async disassociateFileWithNote(file: FileItem, note: SNNote): Promise { return this.changeItem(file, (mutator) => { mutator.removeNote(note) }) } public async addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise { 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 }), ) } public async addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise { 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 } 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.addFile(file) }) as Promise }), ) } public async linkNoteToNote(note: SNNote, otherNote: SNNote): Promise { return this.changeItem(note, (mutator) => { mutator.addNote(otherNote) }) } public async linkFileToFile(file: FileItem, otherFile: FileItem): Promise { return this.changeItem(file, (mutator) => { mutator.addFile(otherFile) }) } public async unlinkItems( itemA: DecryptedItemInterface, itemB: DecryptedItemInterface, ): Promise> { 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) }) } }