// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {BlockIcons} from './blockIcons' import {Block, BlockPatch, createPatchesFromBlocks} from './blocks/block' import {Board, IPropertyOption, IPropertyTemplate, PropertyType, createBoard} from './blocks/board' import {BoardView, ISortOption, createBoardView, KanbanCalculationFields} from './blocks/boardView' import {Card, createCard} from './blocks/card' import {FilterGroup} from './blocks/filterGroup' import octoClient, {OctoClient} from './octoClient' import {OctoUtils} from './octoUtils' import undoManager from './undomanager' import {Utils, IDType} from './utils' import {UserSettings} from './userSettings' import TelemetryClient, {TelemetryCategory, TelemetryActions} from './telemetry/telemetryClient' // // The Mutator is used to make all changes to server state // It also ensures that the Undo-manager is called for each action // class Mutator { private undoGroupId?: string private undoDisplayId?: string private beginUndoGroup(): string | undefined { if (this.undoGroupId) { Utils.assertFailure('UndoManager does not support nested groups') return undefined } this.undoGroupId = Utils.createGuid(IDType.None) return this.undoGroupId } private endUndoGroup(groupId: string) { if (this.undoGroupId !== groupId) { Utils.assertFailure('Mismatched groupId. UndoManager does not support nested groups') return } this.undoGroupId = undefined } async performAsUndoGroup(actions: () => Promise): Promise { const groupId = this.beginUndoGroup() try { await actions() } catch (err) { Utils.assertFailure(`ERROR: ${err}`) } if (groupId) { this.endUndoGroup(groupId) } } async updateBlock(newBlock: Block, oldBlock: Block, description: string): Promise { const [updatePatch, undoPatch] = createPatchesFromBlocks(newBlock, oldBlock) await undoManager.perform( async () => { await octoClient.patchBlock(newBlock.id, updatePatch) }, async () => { await octoClient.patchBlock(oldBlock.id, undoPatch) }, description, this.undoGroupId, ) } private async updateBlocks(newBlocks: Block[], oldBlocks: Block[], description: string): Promise { if (newBlocks.length !== oldBlocks.length) { throw new Error('new and old blocks must have the same length when updating blocks') } const updatePatches = [] as BlockPatch[] const undoPatches = [] as BlockPatch[] newBlocks.forEach((newBlock, i) => { const [updatePatch, undoPatch] = createPatchesFromBlocks(newBlock, oldBlocks[i]) updatePatches.push(updatePatch) undoPatches.push(undoPatch) }) return undoManager.perform( async () => { await octoClient.patchBlocks(newBlocks, updatePatches) }, async () => { await octoClient.patchBlocks(newBlocks, undoPatches) }, description, this.undoGroupId, ) } //eslint-disable-next-line no-shadow async insertBlock(block: Block, description = 'add', afterRedo?: (block: Block) => Promise, beforeUndo?: (block: Block) => Promise): Promise { return undoManager.perform( async () => { const res = await octoClient.insertBlock(block) const jsonres = await res.json() const newBlock = jsonres[0] as Block await afterRedo?.(newBlock) return newBlock }, async (newBlock: Block) => { await beforeUndo?.(newBlock) await octoClient.deleteBlock(newBlock.id) }, description, this.undoGroupId, ) } //eslint-disable-next-line no-shadow async insertBlocks(blocks: Block[], description = 'add', afterRedo?: (blocks: Block[]) => Promise, beforeUndo?: () => Promise) { return undoManager.perform( async () => { const res = await octoClient.insertBlocks(blocks) const newBlocks = (await res.json()) as Block[] await afterRedo?.(newBlocks) return newBlocks }, async (newBlocks: Block[]) => { await beforeUndo?.() const awaits = [] for (const block of newBlocks) { awaits.push(octoClient.deleteBlock(block.id)) } await Promise.all(awaits) }, description, this.undoGroupId, ) } async deleteBlock(block: Block, description?: string, beforeRedo?: () => Promise, afterUndo?: () => Promise) { const actualDescription = description || `delete ${block.type}` await undoManager.perform( async () => { await beforeRedo?.() await octoClient.deleteBlock(block.id) }, async () => { await octoClient.insertBlock(block) await afterUndo?.() }, actualDescription, this.undoGroupId, ) } async changeTitle(blockId: string, oldTitle: string, newTitle: string, description = 'change title') { await undoManager.perform( async () => { await octoClient.patchBlock(blockId, {title: newTitle}) }, async () => { await octoClient.patchBlock(blockId, {title: oldTitle}) }, description, this.undoGroupId, ) } async setDefaultTemplate(blockId: string, oldTemplateId: string, templateId: string, description = 'set default template') { await undoManager.perform( async () => { await octoClient.patchBlock(blockId, {updatedFields: {defaultTemplateId: templateId}}) }, async () => { await octoClient.patchBlock(blockId, {updatedFields: {defaultTemplateId: oldTemplateId}}) }, description, this.undoGroupId, ) } async clearDefaultTemplate(blockId: string, oldTemplateId: string, description = 'set default template') { await undoManager.perform( async () => { await octoClient.patchBlock(blockId, {updatedFields: {defaultTemplateId: ''}}) }, async () => { await octoClient.patchBlock(blockId, {updatedFields: {defaultTemplateId: oldTemplateId}}) }, description, this.undoGroupId, ) } async changeIcon(blockId: string, oldIcon: string|undefined, icon: string, description = 'change icon') { await undoManager.perform( async () => { await octoClient.patchBlock(blockId, {updatedFields: {icon}}) }, async () => { await octoClient.patchBlock(blockId, {updatedFields: {icon: oldIcon}}) }, description, this.undoGroupId, ) } async changeDescription(blockId: string, oldBlockDescription: string|undefined, blockDescription: string, description = 'change description') { await undoManager.perform( async () => { await octoClient.patchBlock(blockId, {updatedFields: {description: blockDescription}}) }, async () => { await octoClient.patchBlock(blockId, {updatedFields: {description: oldBlockDescription}}) }, description, this.undoGroupId, ) } async showDescription(boardId: string, oldShowDescription: boolean, showDescription = true, description?: string) { let actionDescription = description if (!actionDescription) { actionDescription = showDescription ? 'show description' : 'hide description' } await undoManager.perform( async () => { await octoClient.patchBlock(boardId, {updatedFields: {showDescription}}) }, async () => { await octoClient.patchBlock(boardId, {updatedFields: {showDescription: oldShowDescription}}) }, actionDescription, this.undoGroupId, ) } async changeCardContentOrder(cardId: string, oldContentOrder: Array, contentOrder: Array, description = 'reorder'): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(cardId, {updatedFields: {contentOrder}}) }, async () => { await octoClient.patchBlock(cardId, {updatedFields: {contentOrder: oldContentOrder}}) }, description, this.undoGroupId, ) } // Property Templates async insertPropertyTemplate(board: Board, activeView: BoardView, index = -1, template?: IPropertyTemplate): Promise { if (!activeView) { Utils.assertFailure('insertPropertyTemplate: no activeView') return '' } const newTemplate = template || { id: Utils.createGuid(IDType.BlockID), name: 'New Property', type: 'text', options: [], } const oldBlocks: Block[] = [board] const newBoard = createBoard(board) // insert at end of board.fields.cardProperties newBoard.fields.cardProperties.push(newTemplate) const changedBlocks: Block[] = [newBoard] let description = 'add property' if (activeView.fields.viewType === 'table') { oldBlocks.push(activeView) const newActiveView = createBoardView(activeView) // insert in proper location in activeview.fields.visiblePropetyIds const viewIndex = index > 0 ? index : activeView.fields.visiblePropertyIds.length newActiveView.fields.visiblePropertyIds.splice(viewIndex, 0, newTemplate.id) changedBlocks.push(newActiveView) description = 'add column' } await this.updateBlocks(changedBlocks, oldBlocks, description) return newTemplate.id } async duplicatePropertyTemplate(board: Board, activeView: BoardView, propertyId: string) { if (!activeView) { Utils.assertFailure('duplicatePropertyTemplate: no activeView') return } const oldBlocks: Block[] = [board] const newBoard = createBoard(board) const changedBlocks: Block[] = [newBoard] const index = newBoard.fields.cardProperties.findIndex((o: IPropertyTemplate) => o.id === propertyId) if (index === -1) { Utils.assertFailure(`Cannot find template with id: ${propertyId}`) return } const srcTemplate = newBoard.fields.cardProperties[index] const newTemplate: IPropertyTemplate = { id: Utils.createGuid(IDType.BlockID), name: `${srcTemplate.name} copy`, type: srcTemplate.type, options: srcTemplate.options.slice(), } newBoard.fields.cardProperties.splice(index + 1, 0, newTemplate) let description = 'duplicate property' if (activeView.fields.viewType === 'table') { oldBlocks.push(activeView) const newActiveView = createBoardView(activeView) newActiveView.fields.visiblePropertyIds.push(newTemplate.id) changedBlocks.push(newActiveView) description = 'duplicate column' } await this.updateBlocks(changedBlocks, oldBlocks, description) } async changePropertyTemplateOrder(board: Board, template: IPropertyTemplate, destIndex: number) { const templates = board.fields.cardProperties const newValue = templates.slice() const srcIndex = templates.indexOf(template) Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) newValue.splice(destIndex, 0, newValue.splice(srcIndex, 1)[0]) const newBoard = createBoard(board) newBoard.fields.cardProperties = newValue await this.updateBlock(newBoard, board, 'reorder properties') } async deleteProperty(board: Board, views: BoardView[], cards: Card[], propertyId: string) { const oldBlocks: Block[] = [board] const newBoard = createBoard(board) const changedBlocks: Block[] = [newBoard] newBoard.fields.cardProperties = board.fields.cardProperties.filter((o: IPropertyTemplate) => o.id !== propertyId) views.forEach((view) => { if (view.fields.visiblePropertyIds.includes(propertyId)) { oldBlocks.push(view) const newView = createBoardView(view) newView.fields.visiblePropertyIds = view.fields.visiblePropertyIds.filter((o: string) => o !== propertyId) changedBlocks.push(newView) } }) cards.forEach((card) => { if (card.fields.properties[propertyId]) { oldBlocks.push(card) const newCard = createCard(card) delete newCard.fields.properties[propertyId] changedBlocks.push(newCard) } }) await this.updateBlocks(changedBlocks, oldBlocks, 'delete property') } // Properties async insertPropertyOption(board: Board, template: IPropertyTemplate, option: IPropertyOption, description = 'add option') { Utils.assert(board.fields.cardProperties.includes(template)) const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)! newTemplate.options.push(option) await this.updateBlock(newBoard, board, description) } async deletePropertyOption(board: Board, template: IPropertyTemplate, option: IPropertyOption) { const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)! newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id) await this.updateBlock(newBoard, board, 'delete option') } async changePropertyOptionOrder(board: Board, template: IPropertyTemplate, option: IPropertyOption, destIndex: number) { const srcIndex = template.options.indexOf(option) Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)! newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0]) await this.updateBlock(newBoard, board, 'reorder options') } async changePropertyOptionValue(board: Board, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) { const oldBlocks: Block[] = [board] const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === propertyTemplate.id)! const newOption = newTemplate.options.find((o) => o.id === option.id)! newOption.value = value const changedBlocks: Block[] = [newBoard] await this.updateBlocks(changedBlocks, oldBlocks, 'rename option') return changedBlocks } async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) { const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)! const newOption = newTemplate.options.find((o) => o.id === option.id)! newOption.color = color await this.updateBlock(newBoard, board, 'change option color') } async changePropertyValue(card: Card, propertyId: string, value?: string | string[], description = 'change property') { const oldValue = card.fields.properties[propertyId] // dont save anything if property value was not changed. if (oldValue === value) { return } const newCard = createCard(card) if (value) { newCard.fields.properties[propertyId] = value } else { delete newCard.fields.properties[propertyId] } await this.updateBlock(newCard, card, description) TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.EditCardProperty, {board: card.rootId, card: card.id}) } async changePropertyTypeAndName(board: Board, cards: Card[], propertyTemplate: IPropertyTemplate, newType: PropertyType, newName: string) { if (propertyTemplate.type === newType && propertyTemplate.name === newName) { return } const newBoard = createBoard(board) const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === propertyTemplate.id)! if (propertyTemplate.type !== newType) { newTemplate.options = [] } newTemplate.type = newType newTemplate.name = newName const oldBlocks: Block[] = [board] const newBlocks: Block[] = [newBoard] if (propertyTemplate.type !== newType) { if (propertyTemplate.type === 'select' || propertyTemplate.type === 'multiSelect') { // If the old type was either select or multiselect const isNewTypeSelectOrMulti = newType === 'select' || newType === 'multiSelect' for (const card of cards) { const oldValue = Array.isArray(card.fields.properties[propertyTemplate.id]) ? (card.fields.properties[propertyTemplate.id].length > 0 && card.fields.properties[propertyTemplate.id][0]) : card.fields.properties[propertyTemplate.id] if (oldValue) { const newValue = isNewTypeSelectOrMulti ? propertyTemplate.options.find((o) => o.id === oldValue)?.id : propertyTemplate.options.find((o) => o.id === oldValue)?.value const newCard = createCard(card) if (newValue) { newCard.fields.properties[propertyTemplate.id] = newType === 'multiSelect' ? [newValue] : newValue } else { // This was an invalid select option, so delete it delete newCard.fields.properties[propertyTemplate.id] } newBlocks.push(newCard) oldBlocks.push(card) } if (isNewTypeSelectOrMulti) { newTemplate.options = propertyTemplate.options } } } else if (newType === 'select' || newType === 'multiSelect') { // if the new type is either select or multiselect // Map values to new template option IDs for (const card of cards) { const oldValue = card.fields.properties[propertyTemplate.id] as string if (oldValue) { let option = newTemplate.options.find((o: IPropertyOption) => o.value === oldValue) if (!option) { option = { id: Utils.createGuid(IDType.None), value: oldValue, color: 'propColorDefault', } newTemplate.options.push(option) } const newCard = createCard(card) newCard.fields.properties[propertyTemplate.id] = newType === 'multiSelect' ? [option.id] : option.id newBlocks.push(newCard) oldBlocks.push(card) } } } } await this.updateBlocks(newBlocks, oldBlocks, 'change property type and name') } // Views async changeViewSortOptions(viewId: string, oldSortOptions: ISortOption[], sortOptions: ISortOption[]): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {sortOptions}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {sortOptions: oldSortOptions}}) }, 'sort', this.undoGroupId, ) } async changeViewFilter(viewId: string, oldFilter: FilterGroup, filter: FilterGroup): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {filter}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {filter: oldFilter}}) }, 'filter', this.undoGroupId, ) } async changeViewGroupById(viewId: string, oldGroupById: string|undefined, groupById: string): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {groupById}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {groupById: oldGroupById}}) }, 'group by', this.undoGroupId, ) } async changeViewDateDisplayPropertyId(viewId: string, oldDateDisplayPropertyId: string|undefined, dateDisplayPropertyId: string): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {dateDisplayPropertyId}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {dateDisplayPropertyId: oldDateDisplayPropertyId}}) }, 'display by', this.undoDisplayId, ) } async changeViewVisiblePropertiesOrder(view: BoardView, template: IPropertyTemplate, destIndex: number, description = 'change property order'): Promise { const oldVisiblePropertyIds = view.fields.visiblePropertyIds const newOrder = oldVisiblePropertyIds.slice() const srcIndex = oldVisiblePropertyIds.indexOf(template.id) Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) newOrder.splice(destIndex, 0, newOrder.splice(srcIndex, 1)[0]) await undoManager.perform( async () => { await octoClient.patchBlock(view.id, {updatedFields: {visiblePropertyIds: newOrder}}) }, async () => { await octoClient.patchBlock(view.id, {updatedFields: {visiblePropertyIds: oldVisiblePropertyIds}}) }, description, this.undoGroupId, ) } async changeViewVisibleProperties(viewId: string, oldVisiblePropertyIds: string[], visiblePropertyIds: string[], description = 'show / hide property'): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {visiblePropertyIds}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {visiblePropertyIds: oldVisiblePropertyIds}}) }, description, this.undoGroupId, ) } async changeViewVisibleOptionIds(viewId: string, oldVisibleOptionIds: string[], visibleOptionIds: string[], description = 'reorder'): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {visibleOptionIds}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {visibleOptionIds: oldVisibleOptionIds}}) }, description, this.undoGroupId, ) } async changeViewHiddenOptionIds(viewId: string, oldHiddenOptionIds: string[], hiddenOptionIds: string[], description = 'reorder'): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {hiddenOptionIds}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {hiddenOptionIds: oldHiddenOptionIds}}) }, description, this.undoGroupId, ) } async changeViewKanbanCalculations(viewId: string, oldCalculations: Record, calculations: Record, description = 'updated kanban calculations'): Promise { await undoManager.perform( async () => { await octoClient.patchBlock(viewId, {updatedFields: {kanbanCalculations: calculations}}) }, async () => { await octoClient.patchBlock(viewId, {updatedFields: {kanbanCalculations: oldCalculations}}) }, description, this.undoGroupId, ) } async hideViewColumn(view: BoardView, columnOptionId: string): Promise { if (view.fields.hiddenOptionIds.includes(columnOptionId)) { return } const newView = createBoardView(view) newView.fields.visibleOptionIds = newView.fields.visibleOptionIds.filter((o) => o !== columnOptionId) newView.fields.hiddenOptionIds = [...newView.fields.hiddenOptionIds, columnOptionId] await this.updateBlock(newView, view, 'hide column') } async unhideViewColumn(view: BoardView, columnOptionId: string): Promise { if (!view.fields.hiddenOptionIds.includes(columnOptionId)) { return } const newView = createBoardView(view) newView.fields.hiddenOptionIds = newView.fields.hiddenOptionIds.filter((o) => o !== columnOptionId) // Put the column at the end of the visible list newView.fields.visibleOptionIds = newView.fields.visibleOptionIds.filter((o) => o !== columnOptionId) newView.fields.visibleOptionIds = [...newView.fields.visibleOptionIds, columnOptionId] await this.updateBlock(newView, view, 'show column') } async changeViewCardOrder(view: BoardView, cardOrder: string[], description = 'reorder'): Promise { const newView = createBoardView(view) newView.fields.cardOrder = cardOrder await this.updateBlock(newView, view, description) } async followBlock(blockId: string, blockType: string, userId: string) { await undoManager.perform( async () => { await octoClient.followBlock(blockId, blockType, userId) }, async () => { await octoClient.unfollowBlock(blockId, blockType, userId) }, 'follow block', this.undoGroupId, ) } async unfollowBlock(blockId: string, blockType: string, userId: string) { await undoManager.perform( async () => { await octoClient.unfollowBlock(blockId, blockType, userId) }, async () => { await octoClient.followBlock(blockId, blockType, userId) }, 'follow block', this.undoGroupId, ) } // Duplicate async duplicateCard( cardId: string, board: Board, description = 'duplicate card', asTemplate = false, afterRedo?: (newCardId: string) => Promise, beforeUndo?: () => Promise, ): Promise<[Block[], string]> { const blocks = await octoClient.getSubtree(cardId, 2) const [newBlocks1, newCard] = OctoUtils.duplicateBlockTree(blocks, cardId) as [Block[], Card, Record] const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`) if (asTemplate === newCard.fields.isTemplate) { // Copy template newCard.title = `${newCard.title} copy` } else if (asTemplate) { // Template from card newCard.title = 'New card template' } else { // Card from template newCard.title = '' // If the template doesn't specify an icon, initialize it to a random one if (!newCard.fields.icon && UserSettings.prefillRandomIcons) { newCard.fields.icon = BlockIcons.shared.randomIcon() } } newCard.fields.isTemplate = asTemplate newCard.rootId = board.id newCard.parentId = board.id await this.insertBlocks( newBlocks, description, async (respBlocks: Block[]) => { const card = respBlocks.find((block) => block.type === 'card') if (card) { await afterRedo?.(card.id) } else { Utils.logError('card not found for opening.') } }, beforeUndo, ) return [newBlocks, newCard.id] } async duplicateBoard( boardId: string, description = 'duplicate board', asTemplate = false, afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise, ): Promise<[Block[], string]> { const blocks = await octoClient.getSubtree(boardId, 3) const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [Block[], Board, Record] const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) if (asTemplate === newBoard.fields.isTemplate) { newBoard.title = `${newBoard.title} copy` } else if (asTemplate) { // Template from board newBoard.title = 'New board template' } else { // Board from template } newBoard.fields.isTemplate = asTemplate const createdBlocks = await this.insertBlocks( newBlocks, description, async (respBlocks: Block[]) => { await afterRedo?.(respBlocks[0].id) }, beforeUndo, ) return [createdBlocks, createdBlocks[0].id] } async duplicateFromRootBoard( boardId: string, description = 'duplicate board', asTemplate = false, afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise, ): Promise<[Block[], string]> { const rootClient = new OctoClient(octoClient.serverUrl, '0') const blocks = await rootClient.getSubtree(boardId, 3, '0') const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [Block[], Board, Record] const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) if (asTemplate === newBoard.fields.isTemplate) { newBoard.title = `${newBoard.title} copy` } else if (asTemplate) { // Template from board newBoard.title = 'New board template' } else { // Board from template } newBoard.fields.isTemplate = asTemplate const createdBlocks = await this.insertBlocks( newBlocks, description, async (respBlocks: Block[]) => { await afterRedo?.(respBlocks[0].id) }, beforeUndo, ) return [createdBlocks, createdBlocks[0].id] } // Other methods // Not a mutator, but convenient to put here since Mutator wraps OctoClient async exportArchive(boardID?: string): Promise { return octoClient.exportArchive(boardID) } // Not a mutator, but convenient to put here since Mutator wraps OctoClient async importFullArchive(blocks: readonly Block[]): Promise { return octoClient.importFullArchive(blocks) } get canUndo(): boolean { return undoManager.canUndo } get canRedo(): boolean { return undoManager.canRedo } get undoDescription(): string | undefined { return undoManager.undoDescription } get redoDescription(): string | undefined { return undoManager.redoDescription } async undo() { await undoManager.undo() } async redo() { await undoManager.redo() } } const mutator = new Mutator() export default mutator export {mutator}