ext/guides/lib/codio-ide-api.js

import _ from 'lodash'

import app from '../../../core/app'
import events from '../../../core/events'
import tools from '../../../core/tools'

import bookLoader from './book/loader/index'
import bookStructureHelper from './book/structure'
import assessments from './assessments/index'
import { getMetadata } from './guides-loader/index'
import helpers from './helpers'
import guidesEvents from './events'
import { BOOK_TYPES } from './consts'
import { getSectionLayoutName, prepareFormData } from './settings/layout/data'

const EVENTS = {
  goToSection: 'guides:section:goto_id',
  editAssessment: 'guides:assessment:edit',
  getGuidesPageInfo: 'guides:player:getPageInfo',
  bookAddChapter: 'guides:book:add_chapter',
  bookAddSection: 'guides:book:add_section',
  bookAddPage: 'guides:book:add_page',
  bookRemoveItem: 'guides:book:remove_item',
  bookRenameItem: 'guides:book:rename_item',
  bookLinkPage: 'guides:book:link_page'
}

const emit = events.create(EVENTS)

const callbacks = {
  guides: {
    onCommandExecute: {},
    onCommandResult: {},
    assessments: {
      onExecute: {},
      onResult: {}
    }
  }
}

const convertActionToInternal = (action) => {
  const baseFields = { action: 'open', panel: action.panel }
  switch (action.type) {
    case window.codioIDE.guides.structure.ACTION_TYPE.FILE:
      return { ...baseFields, path: action.fileName }
    case window.codioIDE.guides.structure.ACTION_TYPE.PREVIEW:
      return { ...baseFields, path: `#preview: ${action.url}` }
    case window.codioIDE.guides.structure.ACTION_TYPE.TERMINAL:
      return { ...baseFields, path: `#terminal: ${action.command}` }
    case window.codioIDE.guides.structure.ACTION_TYPE.HIGHLIGHT:
      return {
        ...baseFields,
        path: action.fileName,
        ref: action.reference,
        lineCount: action.lines
      }
    case window.codioIDE.guides.structure.ACTION_TYPE.VISUALIZER:
      return {
        ...baseFields,
        path: `#tutor: ${action.fileName}`
      }
    case window.codioIDE.guides.structure.ACTION_TYPE.VM:
      return { ...baseFields, path: `#vm: ${action.fileName}` }
    case window.codioIDE.guides.structure.ACTION_TYPE.VM_TERMINAL:
      return { ...baseFields, path: `#vmssh: ${action.command}` }
    case window.codioIDE.guides.structure.ACTION_TYPE.EARSKETCH:
      return { ...baseFields, path: `#earsketch: ${action.fileName}` }
    case window.codioIDE.guides.structure.ACTION_TYPE.JUPYTER_LAB:
      return { ...baseFields, path: `#jupyter-lab: ${action.fileName}` }
  }
}

const convertMediaActionToInternal = (action) => {
  const baseFields = {
    type: action.type,
    time: action.time,
    content: action.fileNameOrCommand || '',
    lines: _.isNumber(action.lines) ? action.lines : null
  }
  switch (action.type) {
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.FILE_OPEN:
      return {
        ...baseFields,
        action: 'open',
        tabType: 'file',
        panel: action.panel
      }
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.FILE_CLOSE:
      return {
        ...baseFields,
        action: 'close',
        tabType: 'file',
        panel: action.panel
      }
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.TERMINAL_OPEN:
      return {
        ...baseFields,
        action: 'open',
        tabType: 'terminal',
        panel: action.panel
      }
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.TERMINAL_CLOSE:
      return {
        ...baseFields,
        action: 'close',
        tabType: 'terminal',
        panel: action.panel
      }
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.RUN_COMMAND:
      return {
        ...baseFields,
        action: 'open',
        tabType: 'terminal',
        panel: action.panel
      }
    case window.codioIDE.guides.structure.MEDIA_ACTION_TYPE.HIGHLIGHT:
      return {
        ...baseFields,
        action: 'open',
        tabType: 'file',
        panel: action.panel,
        reference: action.reference
      }
    default:
      return baseFields
  }
}

const convertInternalActionToOptions = (action) => {
  if (action.action === 'close') {
    return
  }
  let type
  const data = {}

  if (action.path.indexOf('#preview: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.PREVIEW
    data.url = action.path.replace('#preview: ', '')
  } else if (action.path.indexOf('#terminal: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.TERMINAL
    data.command = action.path.replace('#terminal: ', '')
  } else if (action.path.indexOf('#tutor: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.VISUALIZER
    data.fileName = action.path.replace('#tutor: ', '')
  } else if (action.path.indexOf('#vm: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.VM
    data.fileName = action.path.replace('#vm: ', '')
  } else if (action.path.indexOf('#vmssh: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.VM_TERMINAL
    data.fileName = action.path.replace('#vmssh: ', '')
  } else if (action.path.indexOf('#earsketch: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.EARSKETCH
    data.fileName = action.path.replace('#earsketch: ', '')
  } else if (action.path.indexOf('#jupyter-lab: ') === 0) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.JUPYTER_LAB
    data.fileName = action.path.replace('#jupyter-lab: ', '')
  } else if (!_.isUndefined(action.ref)) {
    type = window.codioIDE.guides.structure.ACTION_TYPE.HIGHLIGHT
    data.fileName = action.path
    data.reference = action.ref
    data.lines = action.lineCount
  } else {
    type = window.codioIDE.guides.structure.ACTION_TYPE.FILE
    data.fileName = action.path
  }
  return {
    type,
    panel: action.panel,
    ...data
  }
}

const convertInternalMediaActionToOptions = (action) => {
  const data = {}
  if (action.action === 'open' || action.action === 'close') {
    data.panel = action.panel
    data.fileNameOrCommand = action.content || ''
  }
  if (action.type === 'highlight') {
    data.reference = action.reference
    data.lines = action.lines
  }
  return {
    type: action.type,
    time: action.time,
    ...data
  }
}

const convertInternalSectionToOptions = async (metadata, section) => {
  if (!section) {
    return null
  }
  const layoutSettings = prepareFormData(metadata, section)
  const content = await window.codioIDE.files.getContent(section.contentPath)
  const sectionJson = section.json
  return {
    title: sectionJson.title,
    layout: layoutSettings.layout,
    guidesOnLeft: layoutSettings.guidesOnLeft,
    actions:
      sectionJson.files
        ?.map(convertInternalActionToOptions)
        .filter((action) => action) || [],
    content,
    showFileTree: layoutSettings.showFileTree,
    showFolders: sectionJson.path,
    closeAllTabs: section.isCloseAll,
    closeTerminalSession: sectionJson.closeTerminalSession,
    teacherOnly: sectionJson.teacherOnly,
    learningObjectives: sectionJson.learningObjectives,
    media: sectionJson.media
      ? {
          ...sectionJson.media,
          actions:
            sectionJson.media.actions?.map(
              convertInternalMediaActionToOptions
            ) || []
        }
      : undefined
  }
}
const convertStructureOptionsToInternal = (options, currentOptions = {}) => {
  const finalOptions = _.merge({ ...currentOptions }, { ...options })

  const actions =
    finalOptions.actions?.map((action) => convertActionToInternal(action)) || []
  if (finalOptions.closeAllTabs) {
    actions.unshift({ path: '#tabs', action: 'close' })
  }

  const layout = getSectionLayoutName(
    finalOptions.layout,
    finalOptions.showFileTree,
    finalOptions.guidesOnLeft
  )

  return {
    type: finalOptions.type,
    title: finalOptions.title,
    value: finalOptions.content,
    files: actions,
    layout: layout,
    path: finalOptions.showFileTree ? finalOptions.showFolders : [],
    teacherOnly: finalOptions.teacherOnly,
    closeTerminalSession: finalOptions.closeTerminalSession,
    learningObjectives: finalOptions.learningObjectives,
    media: finalOptions.media
      ? {
          type: 'audio',
          name: finalOptions.media.name,
          source: finalOptions.media.source,
          disabled: !!finalOptions.media.disabled,
          actions: finalOptions.media.actions?.map(convertMediaActionToInternal)
        }
      : undefined
  }
}

const activateTab = async (tabName) => {
  const tab = app.ide.panels.getOpenedTabs(tabName)[0]
  if (!tab) {
    throw new Error('tab not found')
  }
  if (tab && !tab.active) {
    return app.ide.panels.open(tabName)
  }
}

const checkEditorOpened = async () => {
  try {
    await activateTab('guides-edit')
  } catch {
    throw new Error('guides editor should be opened')
  }
}

/**
 * Codio IDE API object
 * @exports window.codioIDE
 * @type {object}
 * @namespace codioIDE
 * @global
 */
window.codioIDE = window.codioIDE || {}

/**
 * Guides API methods
 * @memberof codioIDE
 * @type {object}
 * @namespace codioIDE.guides
 */
window.codioIDE.guides = {
  /**
   * Returns guides metadata object
   * @memberof codioIDE.guides
   * @async
   * @method getMetadata
   */
  getMetadata: async () => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    return getMetadata()
  },
  /**
   * Returns unused guides metadata info
   * @memberof codioIDE.guides
   * @async
   * @method getUnusedMetadataInfo
   */
  getUnusedMetadataInfo: async () => {
    const metadata = await getMetadata()
    return metadata.getUnusedMetadataInfo()
  },
  /**
   * Recalculates guides metadata caches
   * @memberof codioIDE.guides
   * @async
   * @method refreshMetadataCaches
   */
  refreshMetadataCaches: async () => {
    const metadata = await getMetadata()
    return metadata.refreshMetadataCaches()
  },
  /**
   * IDE guides structure API methods
   * @memberof codioIDE.guides
   * @type {object}
   * @namespace codioIDE.guides.structure
   */
  structure: {
    /**
     * @typedef codioIDE.guides.structure.GuidesStructureItem
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {string} id - item id
     * @property {string} title - title
     * @property {string} [pageId] - page id if item has content
     * @property {codioIDE.structure.ITEM_TYPES} type - item type
     * @property {codioIDE.guides.structure.GuidesStructureItem} [children] - children if item is a sections or chapter
     */
    /**
     * @typedef codioIDE.guides.structure.GuidesStructure
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {string} name - name, obsolete
     * @property {codioIDE.guides.structure.GuidesStructureItem} children - guides children(chapters, sections, pages)
     */
    /**
     * @typedef codioIDE.guides.structure.ActionBase
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {codioIDE.guides.structure.ACTION_TYPE} type - action id
     * @property {number} panel - action panel
     */
    /**
     * @typedef {codioIDE.guides.structure.ActionBase} codioIDE.guides.structure.ActionWithFile
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.ActionBase
     * @type {Object}
     * @property {string} fileName - file name
     */
    /**
     * @typedef {codioIDE.guides.structure.ActionBase} codioIDE.guides.structure.ActionPreview
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.ActionBase
     * @type {Object}
     * @property {string} url - url
     */
    /**
     * @typedef {codioIDE.guides.structure.ActionWithFile} codioIDE.guides.structure.ActionHighlight
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.ActionWithFile
     * @type {Object}
     * @property {string} reference - reference
     * @property {string} [lines] - lines count
     */
    /**
     * @typedef {codioIDE.guides.structure.ActionBase} codioIDE.guides.structure.ActionTerminal
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.ActionBase
     * @type {Object}
     * @property {string} command - command
     */
    /**
     * @typedef codioIDE.guides.structure.MediaActionBase
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {codioIDE.guides.structure.MEDIA_ACTION_TYPE} type - action type
     * @property {number} time - time in seconds
     */
    /**
     * @typedef {codioIDE.guides.structure.MediaActionBase} codioIDE.guides.structure.MediaTabAction
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.MediaActionBase
     * @type {Object}
     * @property {string} fileNameOrCommand - filename or command for terminal
     * @property {number} panel - panel number
     */
    /**
     * @typedef {codioIDE.guides.structure.MediaTabAction} codioIDE.guides.structure.MediaActionHighlight
     * @memberof codioIDE.guides.structure
     * @extends codioIDE.guides.structure.MediaTabAction
     * @type {Object}
     * @property {string} reference - reference
     * @property {number} lines - lines count
     */
    /**
     * @typedef codioIDE.guides.structure.MediaOptions
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {string} name - name
     * @property {string} source - fileName, should exist into .guides/media folder
     * @property {Array.<(codioIDE.guides.structure.MediaTabAction|codioIDE.guides.structure.MediaActionBase|codioIDE.guides.structure.MediaActionHighlight)>} actions - actions
     * @property {boolean} [disabled] - disable media
     */
    /**
     * @typedef codioIDE.guides.structure.UpdateOptions
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {string} [title] - title
     * @property {codioIDE.guides.structure.LAYOUT} [layout] - layout
     * @property {boolean} [guidesOnLeft] - guides on left
     * @property {string} [content] - content
     * @property {Array.<(codioIDE.guides.structure.ActionBase|codioIDE.guides.structure.ActionWithFile|codioIDE.guides.structure.ActionPreview|codioIDE.guides.structure.ActionHighlight|codioIDE.guides.structure.ActionTerminal)>} [actions] - page actions that will be executed on page open
     * @property {boolean} [showFileTree] - show file tree
     * @property {string[]} [showFolders] - paths, show only the selected folders in filetree
     * @property {boolean} [closeAllTabs] - close terminal session on page open
     * @property {boolean} [closeTerminalSession] - close terminal session on page open
     * @property {boolean} [teacherOnly] - if true - item will be visible only for teachers
     * @property {boolean} [learningObjectives] - learning objectives, for items with content
     * @property {codioIDE.guides.structure.MediaOptions} [media] - item media settings, for items with content
     */
    /**
     * @typedef {codioIDE.guides.structure.UpdateOptions} codioIDE.guides.structure.AddOptions
     * @extends codioIDE.guides.structure.UpdateOptions
     * @memberof codioIDE.guides.structure
     * @type {Object}
     * @property {codioIDE.guides.structure.ITEM_TYPES} type - item type
     * @property {string} title - title
     */
    /**
     * @typedef codioIDE.guides.structure.GetResult
     * @memberOf codioIDE.guides.structure
     * @type {Object}
     * @property {codioIDE.guides.structure.GuidesStructureItem} structure - guides structure item
     * @property {codioIDE.guides.structure.UpdateOptions|null} settings - guides section data
     */
    /**
     * Returns guides book structure
     * @memberof codioIDE.guides.structure
     * @async
     * @method getStructure
     * @return {Promise<codioIDE.guides.structure.GuidesStructure>}
     * @throws Will throw an error if the project is not found
     * @example
     * const structure = await codioIDE.guides.structure.getStructure()
     * // return guides structure object: {name: '...', children: [{id: '...', title: '...', type: '...', children: [...]}]}
     */
    getStructure: async () => {
      if (!app.currentProject) {
        throw new Error('project not found')
      }
      return bookLoader.getBookStructure()
    },
    /**
     * Add item to the guides book structure
     * @memberof codioIDE.guides.structure
     * @async
     * @method add
     * @param {codioIDE.guides.structure.AddOptions} settings - item settings
     * @param {string} [parent] - parent item id
     * @param {number} [index] - index to insert
     * @throws Will throw an error if the project is not found or guides is not opened
     * @return {Promise<codioIDE.guides.structure.GuidesStructureItem>}
     * @example
     * try {
     *   const res = await window.codioIDE.guides.structure.add({type: window.codioIDE.guides.structure.ITEM_TYPES.PAGE, value: 'new content'}, null, 2)
     *   console.log('add item result', res) // returns added item: {id: '...', title: '...', type: '...', children: [...]}
     * } catch (e) {
     *   console.error(e)
     * }
     */
    add: async (settings, parent, index = Number.MAX_SAFE_INTEGER) => {
      if (!app.currentProject) {
        throw new Error('project not found')
      }
      if (!settings?.type) {
        throw new Error('settings should have type property')
      }
      if (!settings?.title) {
        throw new Error('settings should have title property')
      }
      await checkEditorOpened()
      const structure = await bookLoader.getBookStructure()

      if (settings.type === BOOK_TYPES.CHAPTER && parent) {
        throw new Error('parent should be null for chapter')
      }
      let parentNode = parent
        ? bookStructureHelper.searchItem(parent, structure)
        : null
      if (
        settings.type === BOOK_TYPES.SECTION &&
        parentNode &&
        parentNode.type !== BOOK_TYPES.CHAPTER
      ) {
        throw new Error('parent should be null or a chapter')
      }
      if (
        settings.type === BOOK_TYPES.PAGE &&
        parentNode?.type === BOOK_TYPES.PAGE
      ) {
        throw new Error('parent should be a chapter or section')
      }
      if (settings.content && !settings.layout) {
        throw new Error('layout is required for items with content')
      }
      const d = tools.promise.deferred()
      parentNode = parentNode || structure
      const { children } = parentNode
      let insertBefore = index >= 0 && index < children.length
      const data = {
        parentId: parent,
        insertInto: index,
        options: convertStructureOptionsToInternal(settings),
        insertBefore,
        callback: d.resolve,
        errorCallback: d.reject
      }
      if (settings.type === BOOK_TYPES.CHAPTER) {
        emit.bookAddChapter(data)
      } else if (settings.type === BOOK_TYPES.SECTION) {
        emit.bookAddSection(data)
      } else {
        emit.bookAddPage(data)
      }
      return d.promise
    },
    /**
     * Update item in the guides book structure
     * @memberof codioIDE.guides.structure
     * @async
     * @method update
     * @throws Will throw an error if the project is not found, guides is not opened, id is not a string, settings is not an object
     * @param {string} id - item id
     * @param {codioIDE.guides.structure.UpdateOptions} settings - item settings
     * @return {Promise<void>}
     * @example
     * try {
     *   await window.codioIDE.guides.structure.update('nodeId', {
     *     title: 'new title'
     *   })
     *   console.log('item updated')
     * } catch (e) {
     *   console.error(e)
     * }
     */
    update: async (id, settings) => {
      if (!app.currentProject) {
        throw new Error('project not found')
      }
      if (!id) {
        throw new Error('id should be a string')
      }
      if (!settings) {
        throw new Error('settings should be an object')
      }
      if (settings.type) {
        throw new Error('type should not be updated')
      }
      await checkEditorOpened()
      const structureP = bookLoader.getBookStructure()
      const metadataP = getMetadata()
      const [structure, metadata] = await Promise.all([structureP, metadataP])
      const node = bookStructureHelper.searchItem(id, structure)
      if (!node) {
        throw new Error('item not found')
      }

      const section = metadata.getSectionById(node.pageId)
      const currentSettings = (await convertInternalSectionToOptions(
        metadata,
        section
      )) || { title: node.title }
      const internalOptions = convertStructureOptionsToInternal(
        settings,
        currentSettings
      )
      const { value, type, ...rest } = internalOptions

      if (settings.title && settings.title !== currentSettings.title) {
        const renameD = tools.promise.deferred()
        emit.bookRenameItem({
          id,
          title: settings.title,
          callback: renameD.resolve,
          errorCallback: renameD.reject
        })
        await renameD.promise
      }
      if (!node.pageId) {
        const linkD = tools.promise.deferred()
        emit.bookLinkPage({
          id,
          link: true,
          options: { value, ...rest },
          callback: linkD.resolve,
          errorCallback: linkD.reject
        })
        return linkD.promise
      }
      const {
        title: titleSettings,
        content: contentSettings,
        ...restSettings
      } = settings
      if (_.isUndefined(contentSettings) && _.isEmpty(restSettings)) {
        return
      }
      if (value) {
        await section.setValue(value)
      }
      await section.update({ ...rest })
      return section.save()
    },
    /**
     * Delete items from the guides book structure
     * @memberof codioIDE.guides.structure
     * @async
     * @method delete
     * @param {string[]} ids - item ids
     * @throws Will throw an error if the project is not found or guides is not opened
     * @return {Promise<void>}
     * @example
     * try {
     *   await window.codioIDE.guides.structure.delete(['nodeId'])
     *   console.log('remove items done')
     * } catch (e) {
     *   console.error(e)
     * }
     */
    delete: async (ids) => {
      if (!app.currentProject) {
        throw new Error('project not found')
      }
      if (!_.isArray(ids)) {
        throw new Error('ids should be an array')
      }
      await checkEditorOpened()
      const structure = await bookLoader.getBookStructure()
      ids = ids.filter((id) => bookStructureHelper.searchItem(id, structure))
      if (!ids.length) {
        return
      }
      const d = tools.promise.deferred()
      emit.bookRemoveItem({ ids, callback: d.resolve, errorCallback: d.reject })
      return d.promise
    },
    /**
     * Get item
     * @param {string} id - item id
     * @return {Promise<codioIDE.guides.structure.GetResult>}
     * @example
     * try {
     *   const res = await window.codioIDE.guides.structure.get('nodeId')
     *   console.log('res', res)
     * } catch (e) {
     *   console.error(e)
     * }
     */
    get: async (id) => {
      if (!app.currentProject) {
        throw new Error('project not found')
      }
      if (!id) {
        throw new Error('id should be a string')
      }
      const structureP = bookLoader.getBookStructure()
      const metadataP = getMetadata()
      const [structure, metadata] = await Promise.all([structureP, metadataP])
      const node = bookStructureHelper.searchItem(id, structure)
      if (!node) {
        throw new Error('item not found')
      }
      const section = node.pageId ? metadata.getSectionById(node.pageId) : null
      const settings = await convertInternalSectionToOptions(metadata, section)
      return { structure: node, settings }
    },
    /**
     * @memberof codioIDE.guides.structure
     * @readonly
     * @enum {string}
     */
    ITEM_TYPES: {
      CHAPTER: BOOK_TYPES.CHAPTER,
      SECTION: BOOK_TYPES.SECTION,
      PAGE: BOOK_TYPES.PAGE
    },
    /**
     * @memberof codioIDE.guides.structure
     * @readonly
     * @enum {string}
     */
    ACTION_TYPE: {
      FILE: 'file',
      PREVIEW: 'preview',
      TERMINAL: 'terminal',
      HIGHLIGHT: 'highlight',
      VISUALIZER: 'visualizer',
      VM: 'vm',
      VM_TERMINAL: 'vm_terminal',
      EARSKETCH: 'earsketch',
      JUPYTER_LAB: 'jupyter_lab'
    },
    /**
     * @memberof codioIDE.guides.structure
     * @readonly
     * @enum {string}
     */
    MEDIA_ACTION_TYPE: {
      FILE_OPEN: 'file_open',
      FILE_CLOSE: 'file_close',
      TERMINAL_OPEN: 'terminal_open',
      TERMINAL_CLOSE: 'terminal_close',
      RUN_COMMAND: 'run_command',
      HIGHLIGHT: 'highlight',
      CLOSE_ALL: 'close_all',
      PAUSE: 'pause'
    },
    /**
     * @memberof codioIDE.guides.structure
     * @readonly
     * @enum {string}
     */
    LAYOUT: {
      L_1_PANEL: '1-panel',
      L_2_PANELS: '2-panels',
      L_3_COLUMNS: '3-columns',
      L_3_CELL: '3-cell',
      L_4_CELL: '4-cell'
    }
  },
  openEditor: (step) => {
    const params = step !== undefined ? { step } : undefined
    guidesEvents.edit(null, params)
  },
  isEditorOpen: guidesEvents.isEditorOpen,
  goToSection: (data) => {
    // sectionTitle, sectionId, optionalAnchor
    emit.goToSection(data)
  },
  getGuidesPageInfo: async () => {
    const guidesTab = app.ide.panels.getOpenedTabs('guides')[0]
    if (!guidesTab) {
      return { content: '', name: '' }
    }
    return new Promise((resolve) => {
      emit.getGuidesPageInfo((info) => resolve(info))
    })
  },
  /**
   * Command execute callback
   * @memberof codioIDE.guides
   * @callback CommandExecuteCallback
   * @param {string} command - command
   */
  /**
   * Register an events listener
   * An event emitted when a command started executing
   * @param {CommandExecuteCallback} callback - command execute callback
   * @throws Will throw an error if the callback is not a function.
   * @return {callback} - call it to deregister the event
   */
  onCommandExecute: (callback) => {
    if (!_.isFunction(callback)) {
      throw new Error('Callback should be a function')
    }
    if (!window.codioIDE.guides.onCommandExecute._handler) {
      window.codioIDE.guides.onCommandExecute._handler = events.subscribe(
        'guides:command:onExecute',
        (topic, data) => {
          _.forEach(callbacks.guides.onCommandExecute, (callback) =>
            callback(data.cmd)
          )
        }
      )
    }
    const id = crypto.randomUUID()
    callbacks.guides.onCommandExecute[id] = callback
    return () => {
      delete callbacks.guides.onCommandExecute[id]
      if (
        window.codioIDE.guides.onCommandExecute._handler &&
        _.isEmpty(callbacks.guides.onCommandExecute)
      ) {
        events.unsubscribe(window.codioIDE.guides.onCommandExecute._handler)
      }
    }
  },
  /**
   * Command result callback
   * @memberof codioIDE.guides
   * @callback CommandResultCallback
   * @param {string} command - command
   * @param {string} output - command output
   * @param {number} format - output format(1 - text, 2 - markdown, 3 - html)
   * @param {string} err - error
   */
  /**
   * Register an events listener
   * An event emitted when a command has executed
   * @param {CommandResultCallback} callback - command result callback
   * @throws Will throw an error if the callback is not a function.
   * @return {callback} - call it to deregister the event
   */
  onCommandResult: (callback) => {
    if (!_.isFunction(callback)) {
      throw new Error('Callback should be a function')
    }
    if (!window.codioIDE.guides.onCommandResult._handler) {
      window.codioIDE.guides.onCommandResult._handler = events.subscribe(
        'guides:command:onResult',
        (topic, { command, output, err }) => {
          _.forEach(callbacks.guides.onCommandResult, (callback) =>
            callback(command, output, err)
          )
        }
      )
    }
    const id = crypto.randomUUID()
    callbacks.guides.onCommandResult[id] = callback
    return () => {
      delete callbacks.guides.onCommandResult[id]
      if (
        window.codioIDE.guides.onCommandResult._handler &&
        _.isEmpty(callbacks.guides.onCommandResult)
      ) {
        events.unsubscribe(window.codioIDE.guides.onCommandResult._handler)
      }
    }
  }
}

/**
 * Guides assessments API methods
 * @memberof codioIDE.guides
 * @type {object}
 * @namespace codioIDE.guides.assessments
 */
window.codioIDE.guides.assessments = {
  /**
   * Returns guides assessments
   * @memberof codioIDE.guides.assessments
   * @async
   * @method list
   */
  list: async () => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    return assessments.assessments.getAssessments()
  },
  findIds: (content, contentType) => {
    return helpers.findAssessmentsIds(content, contentType)
  },
  openEditor: (assessment) => {
    emit.editAssessment(assessment)
  },
  /**
   * Assessment execute callback
   * @memberof codioIDE.guides.assessments
   * @callback AssessmentExecuteCallback
   * @param {string} assessmentId - assessment id
   */
  /**
   * Register an events listener
   * An event emitted when an assignment started executing
   * @param {AssessmentExecuteCallback} callback - assessment execute callback
   * @throws Will throw an error if the callback is not a function.
   * @return {callback} - call it to deregister the event
   */
  onExecute: (callback) => {
    if (!_.isFunction(callback)) {
      throw new Error('Callback should be a function')
    }
    if (!window.codioIDE.guides.assessments.onExecute._handler) {
      window.codioIDE.guides.assessments.onExecute._handler = events.subscribe(
        'guides:assessments:onExecute',
        (topic, assessmentId) => {
          _.forEach(callbacks.guides.assessments.onExecute, (callback) =>
            callback(assessmentId)
          )
        }
      )
    }
    const id = crypto.randomUUID()
    callbacks.guides.assessments.onExecute[id] = callback
    return () => {
      delete callbacks.guides.assessments.onExecute[id]
      if (
        window.codioIDE.guides.assessments.onExecute._handler &&
        _.isEmpty(callbacks.guides.assessments.onExecute)
      ) {
        events.unsubscribe(
          window.codioIDE.guides.assessments.onExecute._handler
        )
      }
    }
  },
  /**
   * Assessment result callback
   * @memberof codioIDE.guides.assessments
   * @callback AssessmentResultCallback
   * @param {string} assessmentId - assessment id
   * @param {string} result - execution result
   */
  /**
   * @typedef codioIDE.guides.assessments.AssessmentResult
   * @memberof codioIDE.guides.assessments
   * @interface
   * @type {Object}
   * @property {string} [output] - output
   * @property {number} [points] - points
   * @property {string} result - assessment result state (fail|success|partial)
   * @property {number} [attempt] - user attempt
   */
  /**
   * Register an events listener
   * An event emitted when an assessment has executed
   * @param {AssessmentResultCallback} callback - assessment result callback
   * @throws Will throw an error if the callback is not a function.
   * @return {callback} - call it to deregister the event
   */
  onResult: (callback) => {
    if (!_.isFunction(callback)) {
      throw new Error('Callback should be a function')
    }
    if (!window.codioIDE.guides.assessments.onResult._handler) {
      window.codioIDE.guides.assessments.onResult._handler = events.subscribe(
        'guides:assessments:onResult',
        (topic, { assessmentId, result }) => {
          _.forEach(callbacks.guides.assessments.onResult, (callback) =>
            callback(assessmentId, result)
          )
        }
      )
    }
    const id = crypto.randomUUID()
    callbacks.guides.assessments.onResult[id] = callback
    return () => {
      delete callbacks.guides.assessments.onResult[id]
      if (
        window.codioIDE.guides.assessments.onResult._handler &&
        _.isEmpty(callbacks.guides.assessments.onResult)
      ) {
        events.unsubscribe(window.codioIDE.guides.assessments.onResult._handler)
      }
    }
  }
}