Files
standardnotes-app-web/packages/snjs/lib/Services/Mutator/MutatorService.ts
2023-08-04 09:25:28 -05:00

714 lines
23 KiB
TypeScript

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<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 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)[]> {
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<DecryptedItemInterface | undefined> {
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<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)
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<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: ComponentInterface,
mutate: (mutator: ComponentMutator) => void,
mutationType: MutationType = MutationType.UpdateUserTimestamps,
emitSource = PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<ComponentInterface> {
const component = this.itemManager.findItem<ComponentInterface>(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<ComponentInterface>(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 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: {
appData: {
[DefaultAppDomain]: {
[AppDataField.UserModifiedDate]: new Date(),
},
},
...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: string,
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> {
const existingItem = this.itemManager.findItem<T>(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<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.TYPES.Tag,
FillItemContent<TagContent>({ title: dto.title }),
true,
dto.createInVault,
)
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 createSmartView<T extends DecryptedItemInterface>(dto: {
title: string
predicate: PredicateInterface<T>
iconString?: string
vault?: VaultListingInterface
}): Promise<SmartView> {
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<SmartView>
}
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 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 findOrCreateTagByTitle(dto: {
title: string
parentItemToLookupUuidFor?: SNTag
createInVault?: VaultListingInterface
}): Promise<SNTag> {
const tag = this.itemManager.findTagByTitleAndParent(dto.title, dto.parentItemToLookupUuidFor)
return tag || this.createTag(dto)
}
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.emitItemFromPayload(item.payloadRepresentation(), source)
}
public async setItemNeedsSync(
item: DecryptedItemInterface,
updateTimestamps = false,
): Promise<DecryptedItemInterface | undefined> {
return this.setItemDirty(item, updateTimestamps)
}
public async setItemsNeedsSync(items: DecryptedItemInterface[]): Promise<(DecryptedItemInterface | undefined)[]> {
return this.setItemsDirty(items)
}
public async deleteItem(item: DecryptedItemInterface | EncryptedItemInterface): Promise<void> {
return this.deleteItems([item])
}
public async deleteItems(items: (DecryptedItemInterface | EncryptedItemInterface)[]): Promise<void> {
await this.setItemsToBeDeleted(items)
}
/**
* Permanently deletes any items currently in the trash. Consumer must manually call sync.
*/
public async emptyTrash(): Promise<void> {
const notes = this.itemManager.trashedItems
await this.setItemsToBeDeleted(notes)
}
public async migrateTagsToFolders(): Promise<void> {
await TagsToFoldersMigrationApplicator.run(this.itemManager, this)
}
public async findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise<SNTag> {
return this.findOrCreateTagByTitle({ title, createInVault })
}
/**
* @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')
}
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<SNTag> {
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<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>
}),
)
}
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
}
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<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)
})
}
}