core/codio-ide-api.js

import _ from 'lodash'

import app from './app'
import events from './events'
import config from './config'
import remoteCommands from './remote-commands'
import { createFile } from './ide/lib/file-helper'
import tools from './tools'

const emit = events.create({
  addMenuItem: 'menu:add',
  removeMenuItem: 'menu:remove',
  updateMenuItem: 'menu:update'
})

const callbacks = {
  onErrorState: {},
  files: {
    onChange: {}
  }
}

window.codioIDE = window.codioIDE || {}

/**
 * IDE files API methods
 * @memberof codioIDE
 * @type {object}
 * @namespace codioIDE.files
 */
window.codioIDE.files = {
  /**
   * Adds a file
   * @memberof codioIDE.files
   * @async
   * @param {string} path - file path
   * @param {string} content - file content
   * @return {Promise<void>}
   */
  add: async (path, content) => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    if (app.currentProject.exists(path)) {
      throw new Error('file already exists')
    }
    const paths = path.split('/')
    const isPathInvalid = _.some(paths, (path) => {
      return !tools.isItemNameValid(path)
    })
    if (isPathInvalid) {
      throw new Error(
        'Path must only contain alphanumeric characters, slashes, hyphens and underscores.'
      )
    }
    await createFile(path, content)
  },
  /**
   * Returns binary file content as base64 encoded string
   * @memberof codioIDE.files
   * @async
   * @method getFileBase64
   * @param path
   * @return {Promise<string>} - base64 encoded string
   */
  getFileBase64: async (path) => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    if (!app.currentProject.exists(path)) {
      throw new Error('file not found')
    }
    const buffer = await app.currentProject.getBinaryFileContent(path)
    return tools.arrayBufferToBase64(buffer)
  },
  /**
   * Returns file content
   * @memberof codioIDE.files
   * @async
   * @method getContent
   * @param {string} path - file path
   */
  getContent: async (path) => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    if (!app.currentProject.exists(path)) {
      throw new Error('file not found')
    }
    const file = await app.currentProject.unixFile(path)
    return file.content
  },
  /**
   * Deletes the files
   * @memberof codioIDE.files
   * @async
   * @method deleteFiles
   * @param {string[]} paths - file paths to delete
   */
  deleteFiles: async (paths) => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    return app.currentProject.deleteItems(paths)
  },
  /**
   * Returns file tree structure
   * @memberof codioIDE.files
   * @method getStructure
   */
  getStructure: () => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    return app.currentProject.children
  },
  /**
   * File change callback
   * @memberof codioIDE.files
   * @callback FileChangeCallback
   * @param {string} path - file path
   * @param {string} content - file content
   */
  /**
   * Register an events listener
   * An event emitted when a file has changed
   * @param {FileChangeCallback} callback - file change callback
   * @throws Will throw an error if the callback is not a function.
   * @return {callback} - call it to deregister the event
   */
  onChange: (callback) => {
    if (!_.isFunction(callback)) {
      throw new Error('Callback should be a function')
    }
    if (!window.codioIDE.files.onChange._handler) {
      window.codioIDE.files.onChange._handler = events.subscribe(
        'file:change:#path',
        (topic, content) => {
          _.forEach(callbacks.files.onChange, (callback) =>
            callback(topic.path, content)
          )
        }
      )
    }
    const id = crypto.randomUUID()
    callbacks.files.onChange[id] = callback
    return () => {
      delete callbacks.files.onChange[id]
      if (
        window.codioIDE.files.onChange._handler &&
        _.isEmpty(callbacks.files.onChange)
      ) {
        events.unsubscribe(window.codioIDE.files.onChange._handler)
      }
    }
  }
}

/**
 * Check is author assignment
 * @memberof codioIDE
 * @method isAuthorAssignment
 */
window.codioIDE.isAuthorAssignment = () => {
  if (!app.currentProject) {
    throw new Error('project not found')
  }
  return !!app.currentProject.authorAssignment
}

/**
 * Error state callback
 * @memberof codioIDE
 * @callback ErrorStateCallback
 * @param {boolean} isError - error state
 * @param {string} [error] - error
 * @param {number} [format] - output format(1 - text, 2 - markdown, 3 - html)
 */
/**
 * Register an events listener
 * An event emitted when an error state has changed
 * @param {ErrorStateCallback} callback - error state change callback
 * @throws Will throw an error if the callback is not a function.
 * @return {callback} - call it to deregister the event
 */
window.codioIDE.onErrorState = (callback) => {
  if (!_.isFunction(callback)) {
    throw new Error('Callback should be a function')
  }
  if (!window.codioIDE.onErrorState._handler) {
    window.codioIDE.onErrorState._handler = events.subscribe(
      'ide:onErrorState',
      (topic, { isError, output, format }) => {
        _.forEach(callbacks.onErrorState, (callback) =>
          callback(isError, output, format)
        )
      }
    )
  }
  const id = crypto.randomUUID()
  callbacks.onErrorState[id] = callback
  return () => {
    delete callbacks.onErrorState[id]
    if (
      window.codioIDE.onErrorState._handler &&
      _.isEmpty(callbacks.onErrorState)
    ) {
      events.unsubscribe(window.codioIDE.onErrorState._handler)
    }
  }
}

/**
 * IDE Menu API methods
 * @memberof codioIDE
 * @type {object}
 * @namespace codioIDE.menu
 */
window.codioIDE.menu = {
  /**
   * @typedef codioIDE.menu.MenuItem
   * @memberof codioIDE.menu
   * @interface
   * @type {Object}
   * @property {string} title - The title of the item. If equals `divider`, then a menu divider will be rendered, and all other options will be ignored.
   * @property {string} [id] - A unique ID.
   * @property {boolean} [group] - When true, will be rendered as group menu item
   * @property {string} [url] - The URL that the user will be redirected to when this item is clicked.
   * @property {boolean} [disabled = false] - When true, will disable this item (default: false).
   * @property {boolean|codioIDE.menu.MenuOnOff} [onOff = false] When true, will treat this item as an on/off checkmark.
   * @property {Object} [params] - Command params as a plain Object, which will be passed to the callback.
   * @property {Object} [context] - Plain Object of contexts to determine if this item will be visible (default: all)
   * @property {string|string[]} [context.session] - Allowed: `all`, `registered` and/or `anonymous`.
   * @property {string|string[]} [context.project] - Allowed: `read`, `write` and/or `admin`.
   * @property {function} [callback] - Function that will be called when this item is clicked.
   */

  /**
   * @typedef codioIDE.menu.MenuItemUpdate
   * @extends codioIDE.menu.MenuItem
   * @memberof codioIDE.menu
   * @interface
   * @property {string} [title] - The title of the item. If equals `divider`, then a menu divider will be rendered, and all other options will be ignored.
   */

  /**
   * @typedef codioIDE.menu.MenuOnOff
   * @memberof codioIDE.menu
   * @interface
   * @type {Object}
   * @property {boolean} [defaultValue = false] - The default value.
   * @property {boolean} [toggle = false] - If true clicking the item will toggle the checkmark.
   */

  /**
   * @typedef codioIDE.menu.MenuItemDescriptor
   * @memberof codioIDE.menu
   * @interface
   * @type {Object}
   * @property {string} [id] - Item id.
   * @property {string} [title] - Item title.
   */

  /**
   * Item added callback function
   *
   * @callback itemAddedCallback
   * @return {string} - item id
   */

  /**
   * Add menu item
   * @memberof codioIDE.menu
   * @method addItem
   * @param {codioIDE.menu.MenuItemDescriptor} parentDescriptor - item descriptor
   * @param {codioIDE.menu.MenuItem} item - menu item
   * @param {itemAddedCallback} [itemAddedCallback] - will be called when on item add with item id
   */
  addItem: (parentDescriptor, item, itemAddedCallback) => {
    emit.addMenuItem({ parentDescriptor, item, itemAddedCallback })
  },
  /**
   * Remove menu item
   * @memberof codioIDE.menu
   * @method removeItem
   * @param {codioIDE.menu.MenuItemDescriptor} itemDescriptor - item descriptor
   */
  removeItem: (itemDescriptor) => {
    emit.removeMenuItem(itemDescriptor)
  },
  /**
   * Update menu item
   * @memberof codioIDE.menu
   * @method updateItem
   * @param {codioIDE.menu.MenuItemDescriptor} itemDescriptor - item descriptor
   * @param {codioIDE.menu.MenuItemUpdate} item - menu item
   */
  updateItem: (itemDescriptor, item) => {
    emit.updateMenuItem({ itemDescriptor, item })
  }
}

/**
 * Returns box url
 * @memberof codioIDE
 * @method getBoxUrl
 * @returns {string}
 */
window.codioIDE.getBoxUrl = () => {
  if (!app.currentProject) {
    throw new Error('project not found')
  }
  return `//${app.currentProject.backendSubDomain}.${config.get('boxDomain')}`
}

/**
 * IDE Remote commands API methods
 * @memberof codioIDE
 * @type {object}
 * @namespace codioIDE.remoteCommand
 */
window.codioIDE.remoteCommand = {
  /**
   * Executes remote command and returns a result as a promise
   * @memberof codioIDE.remoteCommand
   * @async
   * @method run
   * @param {string|string[]} command - command to execute
   * @returns {Promise}
   */
  run: async (command) => {
    if (!app.currentProject) {
      throw new Error('project not found')
    }
    return remoteCommands.run(command)
  }
}