adapt-authoring-adaptframework/lib/AdaptFrameworkImport.js

import { App } from 'adapt-authoring-core'
import fs from 'fs-extra'
import { glob } from 'glob'
import octopus from 'adapt-octopus'
import path from 'upath'
import semver from 'semver'
import { exec } from 'child_process'

import AdaptFrameworkUtils from './AdaptFrameworkUtils.js'

import ComponentTransform from './migrations/component.js'
import ConfigTransform from './migrations/config.js'
import GraphicSrcTransform from './migrations/graphic-src.js'
import NavOrderTransform from './migrations/nav-order.js'
import ParentIdTransform from './migrations/parent-id.js'
import RemoveUndefTransform from './migrations/remove-undef.js'
import StartPageTransform from './migrations/start-page.js'
import ThemeUndefTransform from './migrations/theme-undef.js'

const ContentMigrations = [
  ComponentTransform,
  ConfigTransform,
  GraphicSrcTransform,
  NavOrderTransform,
  ParentIdTransform,
  RemoveUndefTransform,
  StartPageTransform,
  ThemeUndefTransform
]

/**
 * Handles the Adapt framework import process
 * @memberof adaptframework
 */
class AdaptFrameworkImport {
  /**
   * Runs the import
   * @param {AdaptFrameworkImportOptions} options
   * @return {Promise<AdaptFrameworkImport>}
   */
  static async run (options) {
    return new AdaptFrameworkImport(options).import()
  }

  /**
   * Returns the schema to be used for a specific content type
   * @param {Object} data The content item
   * @return {String} The schema name
   */
  static typeToSchema (data) {
    switch (data._type) {
      case 'menu':
      case 'page':
        return 'contentobject'
      case 'component':
        return `${data._component}-${data._type}`
      default:
        return data._type
    }
  }

  /**
   * Options to be passed to AdaptFrameworkBuild
   * @typedef {Object} AdaptFrameworkImportOptions
   * @property {String} unzipPath
   * @property {String} userId
   * @property {Boolean} importContent
   * @property {Boolean} importPlugins
   * @property {Boolean} updatePlugins
   *
   * @constructor
   * @param {AdaptFrameworkImportOptions} options
   */
  constructor ({ unzipPath, userId, assetFolders, tags, isDryRun, importContent = true, importPlugins = true, migrateContent = true, updatePlugins = false }) {
    try {
      if (!unzipPath || !userId) throw new Error()
      /**
       * Reference to the package.json data
       * @type {Object}
       */
      this.pkg = undefined
      /**
       * Path that the import will be unzipped to
       * @type {String}
       */
      this.unzipPath = unzipPath
      /**
       * List of asset folders to check
       * @type {Array<String>}
       */
      this.assetFolders = assetFolders ?? ['assets']
      /**
       * List of asset metadata
       * @type {Array}
       */
      this.assetData = []
      /**
       * List of tags to apply to the course
       * @type {Array<String>}
       */
      this.tags = tags
      /**
       * Path to the import course folder
       * @type {String}
       */
      this.coursePath = undefined
      /**
       * A cache of the import's content JSON file data (note this is not the DB data used by the application)
       * @type {Object}
       */
      this.contentJson = {
        course: {},
        contentObjects: []
      }
      /**
       * Key/value store of the installed content plugins
       * @type {Object}
       */
      this.usedContentPlugins = {}
      /**
       * All plugins installed during the import as a name -> metadata map
       * @type {Object}
       */
      this.newContentPlugins = {}
      /**
       * Key/value store mapping old component keys to component names
       * @type {Object}
       */
      this.componentNameMap = {}
      /**
       * A key/value map of asset file names to new asset ids
       * @type {Object}
       */
      this.assetMap = {}
      /**
       * A key/value map of old ids to new ids
       * @type {Object}
       */
      this.idMap = {}
      /**
       * The _id of the user initiating the import
       * @type {String}
       */
      this.userId = userId
      /**
       * Contains non-fatal infomation messages regarding import status which can be return as response data. Fatal errors are thrown in the usual way.
       * @type {Object}
       */
      this.statusReport = {
        info: [],
        warn: []
      }
      /**
       * User-defined settings related to what is included with the import
       * @type {Object}
       */
      this.settings = {
        isDryRun,
        importContent,
        importPlugins,
        migrateContent,
        updatePlugins
      }

      /**
       * plugins on import that are of a lower version than the installed version
       * @type {Array}
       */
      this.pluginsToMigrate = ['core']
    } catch (e) {
      throw App.instance.errors.FW_IMPORT_INVALID_COURSE
    }
  }

  /**
   * Imports a course zip to the database
   * @return {Promise} Resolves with the current import instance
   */
  async import () {
    let error
    try {
      const [
        assets,
        content,
        contentplugin,
        courseassets,
        framework,
        jsonschema
      ] = await App.instance.waitForModule('assets', 'content', 'contentplugin', 'courseassets', 'adaptframework', 'jsonschema')
      /**
       * Cached module instance for easy access
       * @type {AssetsModule}
       */
      this.assets = assets
      /**
       * Cached module instance for easy access
       * @type {ContentModule}
       */
      this.content = content
      /**
       * Cached module instance for easy access
       * @type {ContentPluginModule}
       */
      this.contentplugin = contentplugin
      /**
       * Cached module instance for easy access
       * @type {CourseAssetsModule}
       */
      this.courseassets = courseassets
      /**
       * Cached module instance for easy access
       * @type {AdaptFrameworkModule}
       */
      this.framework = framework
      /**
       * Cached module instance for easy access
       * @type {JsonSchemaModule}
       */
      this.jsonschema = jsonschema

      AdaptFrameworkUtils.log('info', `running with settings ${JSON.stringify(this.settings, null, 2)}`)
      AdaptFrameworkUtils.log('debug', 'IMPORT_SETTINGS', JSON.stringify(this.settings, null, 2))
      AdaptFrameworkUtils.log('debug', 'IMPORT_USER',  this.userId)

      const { isDryRun, importContent, importPlugins } = this.settings
      const tasks = [
        [this.prepare],
        [this.loadAssetData],
        [this.loadPluginData],
        [this.importTags, importContent],
        [this.importCourseAssets, importContent],
        [this.importCoursePlugins, isDryRun && importPlugins],
        [this.importCoursePlugins, !isDryRun && importContent],
        [this.loadCourseData, isDryRun && importContent],
        [this.migrateCourseData, !isDryRun && importContent],
        [this.loadCourseData, !isDryRun && importContent],
        [this.importCourseData, !isDryRun && importContent],
      ];
      for (const [func, test] of tasks) {
       if(test === true || test === undefined) await func.call(this)
      }
    } catch (e) {
      error = e
    }
    await this.cleanUp(error)
    if (error) throw error
    return this
  }

  /**
   * Performs preliminary checks to confirm that a course is suitable for import
   * @return {Promise}
   */
  async prepare () {
    try { // if it's a nested zip, move everything up a level
      const files = await fs.readdir(this.unzipPath)
      if (files.length === 1) {
        const nestDir = `${this.unzipPath}/${files[0]}`
        await fs.stat(`${nestDir}/package.json`)
        const newDir = path.join(`${this.unzipPath}_2`)
        await fs.move(nestDir, newDir)
        await fs.remove(this.unzipPath)
        this.unzipPath = newDir
      }
    } catch (e) {
      // nothing to do
    }
    // find and store the course data path
    await Promise.allSettled([`${this.unzipPath}/src/course`, `${this.unzipPath}/build/course`].map(async f => {
      await fs.stat(f)
      this.coursePath = f
    }))
    AdaptFrameworkUtils.logDir('unzipPath', this.unzipPath)

    if (!this.coursePath) {
      throw App.instance.errors.FW_IMPORT_INVALID_COURSE
    }
    AdaptFrameworkUtils.logDir('coursePath', this.coursePath)

    try {
      /** @ignore */this.pkg = await fs.readJson(`${this.unzipPath}/package.json`)
    } catch (e) {
      throw App.instance.errors.FW_IMPORT_INVALID
    }
    try {
      await fs.rm(`${this.unzipPath}/package-lock.json`)
    } catch (e) {}

    if (!semver.satisfies(this.pkg.version, semver.major(this.framework.version).toString())) {
      const data = { installed: this.framework.version, import: this.pkg.version }
      if(!this.settings.migrateContent) {
        throw App.instance.errors.FW_IMPORT_INCOMPAT
          .setData(data)
      }
      this.statusReport.info.push({ code: 'MIGRATE_CONTENT', data })
    }
    await this.convertSchemas()
    AdaptFrameworkUtils.log('debug', 'preparation tasks completed successfully')
  }

  /**
   * Converts all properties.schema files to a valid JSON schema format
   * @return {Promise}
   */
  async convertSchemas () {
    return octopus.runRecursive({
      cwd: this.unzipPath,
      logger: { log: (...args) => AdaptFrameworkUtils.log('debug', ...args) }
    })
  }

  /**
   * Writes the contents of 2-customStyles.less to course.json file. Unfortunately it's necessary to do it this way to ensure it's included in migrations.
   */
  async patchCustomStyle () {
    const [customStylePath] = await glob(`**/2-customStyles.less`, { cwd: this.unzipPath, absolute: true })
    const [courseJsonPath] = await glob('**/course.json', { cwd: this.coursePath, absolute: true })
    if (!customStylePath) {
      return
    }
    try {
      const customStyle = await fs.readFile(customStylePath, 'utf8')
      const courseJson = await fs.readJSON(courseJsonPath)
      await fs.writeJSON(courseJsonPath, { customStyle, ...courseJson })
      AdaptFrameworkUtils.log('info', 'patched course customStyle')
    } catch(e) {
      AdaptFrameworkUtils.log('warn', 'failed to patch course customStyle', e)
    }
  }
  
  /**
   * Ensures _theme exists on the config
   */
  async patchThemeName () {
    const [configJsonPath] = await glob('**/config.json', { cwd: this.coursePath, absolute: true })
    try {
      const configJson = await fs.readJSON(configJsonPath)
      if(configJson._theme) return
      await fs.writeJSON(configJsonPath, { ...configJson, _theme: Object.values(this.usedContentPlugins).find(p => p.type === 'theme').name })
      AdaptFrameworkUtils.log('info', 'patched config _theme')
    } catch(e) {
      AdaptFrameworkUtils.log('warn', 'failed to patch config _theme', e)
    }
  }

  /**
   * Loads and caches all asset data either manually or using the assets.json file
   * @return {Promise}
   */
  async loadAssetData () {
    this.assetData = []
    const metaFiles = await glob(`${this.coursePath}/*/assets.json`, { absolute: true })
    if (metaFiles.length) { // process included asset metadata
      AdaptFrameworkUtils.log('debug', 'processing metadata files', metaFiles)
      await Promise.all(metaFiles.map(async f => {
        const metaJson = await fs.readJson(f)
        Object.entries(metaJson).forEach(([filename, metadata]) => this.assetData.push({ filename, ...metadata }))
      }))
    } else { // process the file metadata manually
      const assetFiles = await glob(`${this.coursePath}/*/*/*`, { absolute: true })
      AdaptFrameworkUtils.log('debug', 'processing asset files manually', assetFiles.length)
      this.assetData.push(...assetFiles.map(f => Object.assign({}, { title: path.basename(f), filepath: f })))
    }
    const hasGlobalTags = !!this.tags.length
    this.assetData.forEach(a => {
      if (!a.description) a.description = a.title
      if (a.tags?.length && a.tags[0].title) a.tags = a.tags.map(t => t.title) // convert from old
      if (hasGlobalTags) a.tags = this.tags.concat(a.tags ?? [])
    })
  }

  /**
   * Loads and caches all course plugins
   * @return {Promise}
   */
  async loadPluginData () {
    const usedPluginPaths = await glob(`${this.unzipPath}/src/+(components|extensions|menu|theme)/*`, { absolute: true })
    const getPluginType = pluginData => {
      for (const type of ['component', 'extension', 'menu', 'theme'])
        if (pluginData[type] !== undefined) return type
    }
    await Promise.all(usedPluginPaths.map(async p => {
      const bowerJson = await fs.readJson(`${p}/bower.json`)
      const { name, version, targetAttribute } = bowerJson
      AdaptFrameworkUtils.log('debug', 'found plugin', name)
      this.usedContentPlugins[path.basename(p)] = { name, path: p, version, targetAttribute, type: getPluginType(bowerJson) }
    }))
  }

  /**
   * Loads and caches all course content
   * @return {Promise}
   */
  async loadCourseData () {
    const files = await glob(`**/*.json`, { cwd: this.coursePath, absolute: true, ignore: { ignored: p => p.name === 'assets.json' } })
    const mapped = await Promise.all(files.map(f => this.loadContentFile(f)))
    this.statusReport.info.push({ code: 'CONTENT_IMPORTED', data: AdaptFrameworkUtils.getImportContentCounts(this.contentJson) })
    AdaptFrameworkUtils.log('info', 'loaded course data successfully')
    return mapped
  }

  /**
   * Loads a single content JSON file
   * @return {Promise}
   */
  async loadContentFile (filePath) {
    const contents = await fs.readJson(filePath)
    if (contents._type === 'course') {
      this.contentJson.course = contents
      return
    }
    if (path.basename(filePath) === 'config.json') {
      this.contentJson.config = {
        _id: 'config',
        _type: 'config',
        _enabledPlugins: Object.keys(this.usedContentPlugins),
        ...contents
      }
      return
    }
    if (Array.isArray(contents)) {
      contents.forEach(c => {
        this.contentJson.contentObjects[c._id] = c
        if (!c._type) {
          AdaptFrameworkUtils.log('warn', App.instance.errors.FW_IMPORT_INVALID_CONTENT.setData({ item: c }))
          this.statusReport.warn.push({ code: 'INVALID_CONTENT', data: c })
        }
      })
    }
    AdaptFrameworkUtils.log('debug', 'LOAD_CONTENT', path.resolve(filePath))
  }

  /**
   * Run grunt task
   * @return {Promise}
   */
  runGruntTask (subTask, { outputDir, captureDir }) {
    return this.execPromise(`npx grunt migration:${subTask} --outputdir=${outputDir} --capturedir=${captureDir}`)
  }

  async execPromise (task) {
    AdaptFrameworkUtils.log('debug', 'EXEC', task)
    return new Promise((resolve, reject) => {
      exec(task, { cwd: this.framework.path }, (error, stdout, stderr) => {
        if (stdout) AdaptFrameworkUtils.log('debug', 'EXEC_STDOUT', task, stdout)
        if (stderr) AdaptFrameworkUtils.log('debug', 'EXEC_STDERR', task, stderr)
        error ? reject(error) : resolve(stdout)
      })
    })
  }

  /**
   * Handle migrate course data, installs adapt-migrations/capture data/adds updated scripts/migrates data
   */
  async migrateCourseData () {
    try {
      await this.patchThemeName()
      await this.patchCustomStyle()

      const folderName = `${this.userId}-migrations`
      const relativePath = path.relative(this.framework.path, this.unzipPath)
      const outputDir = fs.existsSync(path.join(this.unzipPath, 'build'))
        ? path.join(relativePath, 'build')
        : path.join(relativePath, 'src')
      const captureDir = path.join('./', folderName)

      AdaptFrameworkUtils.logDir('captureDir', captureDir)
      AdaptFrameworkUtils.logDir('outputDir', outputDir)
      
      const opts = { outputDir: outputDir, captureDir: captureDir }
      await this.runGruntTask('capture', opts)
      await this.runGruntTask('migrate', opts)

      const captureFolder = path.join(this.framework.path, captureDir)
      await fs.remove(captureFolder)
    } catch (error) {
      AdaptFrameworkUtils.log('error', 'Migration process failed', error)
      throw new Error(`Migration process failed: ${error.message}`)
    }
  }

  /**
   * Imports any specified tags
   * @return {Promise}
   */
  async importTags () {
    const tags = await App.instance.waitForModule('tags')
    const existingTagMap = (await tags.find()).reduce((memo, t) => Object.assign(memo, { [t.title]: t._id.toString() }), {})
    const newTags = new Set()
    const course = this.contentJson.course
    // process course tags
    course?.tags?.forEach(t => {
      if (!existingTagMap[t]) newTags.push(t)
      this.tags.push(t)
    })
    // determine any new asset tags
    this.assetData.forEach(a => {
      a.tags?.forEach(t => !existingTagMap[t] && newTags.add(t))
    })
    // return early on dry runs
    if (this.settings.isDryRun) {
      this.statusReport.info.push({ code: 'TAGS_IMPORTED', data: { count: newTags.length } })
      return
    }
    // insert new asset tags
    await Promise.all(Array.from(newTags).map(async n => {
      const { _id } = await tags.insert({ title: n })
      existingTagMap[n] = _id.toString()
    }))
    // map tags from titles to new _ids
    this.tags = this.tags.map(t => existingTagMap[t])
    this.assetData.forEach(data => {
      data.tags = data.tags?.map(t => existingTagMap[t])
    })
    if (course.tags) {
      course.tags = course.tags.map(t => existingTagMap[t])
    }
    AdaptFrameworkUtils.log('debug', 'imported tags successfully')
  }

  /**
   * Imports course asset files
   * @return {Promise}
   */
  async importCourseAssets () {
    let imagesImported = this.settings.isDryRun ? this.assetData.length : 0
    await Promise.all(this.assetData.map(async data => {
      const filepath = data.filepath ?? (await glob(`${this.coursePath}/*/*/${data.filename}`, { absolute: true }))[0]
      // remove unused filepath to avoid possible issues
      delete data.filepath
      if (this.settings.isDryRun) {
        return
      }
      try {
        const asset = await this.assets.insert({
          ...data,
          createdBy: this.userId,
          file: {
            filepath,
            originalFilename: filepath
          },
          tags: data.tags
        })
        // store the asset _id so we can map it to the old path later
        const resolved = path.relative(`${this.coursePath}/..`, filepath)
        this.assetMap[resolved] = asset._id.toString()
      } catch (e) {
        this.statusReport.warn.push({ code: 'ASSET_IMPORT_FAILED', data: { filepath } })
      }
      imagesImported++
    }))
    AdaptFrameworkUtils.log('debug', 'imported course assets successfully')
    this.statusReport.info.push({ code: 'ASSETS_IMPORTED_SUCCESSFULLY', data: { count: imagesImported } })
  }

  /**
   * Imports course content plugins
   * @return {Promise}
   */
  async importCoursePlugins () {
    this.installedPlugins = (await this.contentplugin.find({})).reduce((m, p) => Object.assign(m, { [p.name]: p }), {})
    const pluginsToInstall = []
    const pluginsToUpdate = []

    if (!this.settings.updatePlugins) {
      this.statusReport.warn.push({ code: 'MANAGED_PLUGIN_UPDATE_DISABLED' })
    }
    Object.keys(this.usedContentPlugins).forEach(p => {
      const installedP = this.installedPlugins[p]
      let { version: importVersion } = this.usedContentPlugins[p]
      if(!semver.valid(importVersion)) {
        if (!installedP) {
          throw App.instance.errors.FW_INVALID_VERSION.setData({ name: p, version: importVersion })
        }
        this.statusReport.warn.push({ code: 'INVALID_PLUGIN_VERSION', data: { name: p, importVersion } })
        importVersion = '0.0.0' // set to a valid version to allow the other logic to run
      }
      if (!installedP) {
        return pluginsToInstall.push(p)
      }
      const { version: installedVersion, isLocalInstall } = installedP
      if (semver.lt(importVersion, installedVersion)) {
        this.statusReport.info.push({ code: 'PLUGIN_INSTALL_MIGRATING', data: { name: p, installedVersion, importVersion } })
        AdaptFrameworkUtils.log('debug', `migrating '${p}@${importVersion}' during import, installed version is newer (${installedVersion})`)
        this.pluginsToMigrate.push(p)
        return
      }
      if (!this.settings.updatePlugins) {
        return
      }
      if (semver.eq(importVersion, installedVersion)) {
        this.statusReport.info.push({ code: 'PLUGIN_INSTALL_NOT_NEWER', data: { name: p, installedVersion, importVersion } })
        AdaptFrameworkUtils.log('debug', `not updating '${p}@${importVersion}' during import, installed version is equal to (${installedVersion})`)
        return
      }
      if (!isLocalInstall) {
        this.statusReport.warn.push({ code: 'MANAGED_PLUGIN_INSTALL_SKIPPED', data: { name: p, installedVersion, importVersion } })
        AdaptFrameworkUtils.log('debug', `cannot update '${p}' during import, plugin managed via UI`)
      }
      pluginsToUpdate.push(p)
    })
    if (pluginsToInstall.length) {
      if (!this.settings.importPlugins) {
        if (this.settings.isDryRun) return this.statusReport.error.push({ code: 'MISSING_PLUGINS', data: pluginsToInstall })
        throw App.instance.errors.FW_IMPORT_MISSING_PLUGINS
          .setData({ plugins: pluginsToInstall.join(', ') })
      }
      const errors = []
      await Promise.all([...pluginsToInstall, ...pluginsToUpdate].map(async p => {
        try {
          // try and infer a targetAttribute if there isn't one
          const pluginBowerPath = path.join(this.usedContentPlugins[p].path, 'bower.json')
          const bowerJson = await fs.readJson(pluginBowerPath)
          if (!bowerJson.targetAttribute) {
            bowerJson.targetAttribute = `_${bowerJson.component || bowerJson.extension || bowerJson.menu || bowerJson.theme}`
            await fs.writeJson(pluginBowerPath, bowerJson, { spaces: 2 })
          }
          if (!this.settings.isDryRun) {
            const [pluginData] = await this.contentplugin.installPlugins([[p, this.usedContentPlugins[p].path]], { strict: true })
            this.newContentPlugins[p] = pluginData
          }
          this.statusReport.info.push({ code: 'INSTALL_PLUGIN', data: { name: p, version: bowerJson.version } })
        } catch (e) {
          if (e.code !== 'EEXIST') {
            AdaptFrameworkUtils.log('error', 'PLUGIN_IMPORT_FAILED', p, e)
            errors.push({ plugin: p, error: e.data.errors[0] })
          } else {
            errors.push(e)
          }
        }
      }))
      if (errors.length) {
        throw App.instance.errors.FW_IMPORT_PLUGINS_FAILED
          .setData({ errors: errors.map(e => App.instance.lang.translate(undefined, e)).join(', ') })
      }
    }
    this.componentNameMap = Object.values({ ...this.installedPlugins, ...this.newContentPlugins }).reduce((m, v) => {
      return { ...m, [v.targetAttribute.slice(1)]: v.name }
    }, {})
    AdaptFrameworkUtils.log('debug', 'imported course plugins successfully')
  }

  /**
   * Imports all course content data
   * @return {Promise}
   */
  async importCourseData () {
    /**
     * Note: the execution order is important here
     * - config requires course to exist
     * - Defaults cannot be applied until the config exists
     * - Everything else requires course + config to exist
     */
    const course = await this.importContentObject({ ...this.contentJson.course, tags: this.tags })
    const config = await this.importContentObject(this.contentJson.config)
    // we need to run an update with the same data to make sure all extension schema settings are applied
    await this.importContentObject({ ...this.contentJson.course, _id: course._id }, { isUpdate: true })

    const { sorted, hierarchy } = await this.getSortedData()
    const errors = []
    for (const ids of sorted) {
      for (const _id of ids) {
        try {
          const itemJson = this.contentJson.contentObjects[_id]
          await this.importContentObject({
            _sortOrder: hierarchy[itemJson._parentId].indexOf(_id) + 1,
            ...itemJson // note that JSON sort order will override the deduced one
          })
        } catch (e) {
          errors.push(e?.data?.schemaName
            ? `${e.data.schemaName} ${_id} ${e.data.errors}`
            : App.instance.lang.translate(undefined, e)
          )
        }
      }
    }
    if (errors.length) throw App.instance.errors.FW_IMPORT_CONTENT_FAILED.setData({ errors: errors.join('; ') })
    AdaptFrameworkUtils.log('debug', 'imported course data successfully')
  }

  /**
   * Sorts the import content objects into a 2D array separating each 'level' of siblings to allow processing without the need to work out whether the parent object exists.
   * @returns {Array<Array<String>>} The sorted list
   */
  getSortedData () {
    const sorted = [[this.contentJson.course._id]]
    const hierarchy = Object.values(this.contentJson.contentObjects).reduce((h, c) => {
      return Object.assign(h, {
        [c._parentId]: [...(h[c._parentId] ?? []), c._id]
      })
    }, {})
    const toSort = Object.keys(hierarchy)
    while (toSort.length) {
      const newLevel = []
      sorted[sorted.length - 1].forEach(_id => {
        newLevel.push(...(hierarchy[_id] ?? []))
        toSort.splice(toSort.indexOf(_id), 1)
      })
      if (!newLevel.length) {
        throw App.instance.errors.FW_IMPORT_UNEXPECTED_STRUCTURE // level has no children, so something's gone wrong
      }
      sorted.push(newLevel)
    }
    return { sorted: sorted.slice(1), hierarchy } // remove course from sorted
  }

  /**
   * Imports a single content object
   * @return {Object} The data to be imported
   * @return {Promise} Resolves with the created document
   */
  async importContentObject (data, options = {}) {
    let insertData = await this.transformData({
      ...data,
      _id: undefined,
      _courseId: this.idMap.course,
      createdBy: this.userId
    })
    const schemaName = AdaptFrameworkImport.typeToSchema(data)
    const schema = await this.content.getSchema(schemaName, insertData)
    try {
      this.extractAssets(schema.built.properties, insertData)
    } catch (e) {
      AdaptFrameworkUtils.log('error', `failed to extract asset data for attribute '${e.attribute}' of schema '${schemaName}', ${e}`)
    }
    insertData = await schema.sanitise(insertData)
    let doc
    const opts = { schemaName, validate: true, useCache: false }
    if (options.isUpdate) {
      doc = await this.content.update({ _id: data._id }, insertData, opts)
    } else {
      doc = await this.content.insert(insertData, opts)
      this.idMap[data._id] = doc._id.toString()
      if (doc._type === 'course') this.idMap.course = this.idMap[data._id]
    }
    return doc
  }

  /**
   * Performs custom data transforms prior to import
   * @param {Object} data Data to transform
   * @return {Promise} Resolves with the transformed data
   */
  async transformData (data) {
    const migrations = [...ContentMigrations, ...this.framework.contentMigrations]
    for (const Migration of migrations) await Migration(data, this)
    return data
  }

  /**
   * Infers the presence of any assets in incoming JSON data
   * @param {Object} schema Schema for the passed data
   * @param {Object} data Data to check
   */
  extractAssets (schema, data) {
    if (!schema) {
      return
    }
    Object.entries(schema).forEach(([key, val]) => {
      if (data[key] === undefined) {
        return
      }
      if (val.properties) {
        this.extractAssets(val.properties, data[key])
      } else if (val?.items?.properties) {
        data[key].forEach(d => this.extractAssets(val.items.properties, d))
      } else if (val?._backboneForms?.type === 'Asset' || val?._backboneForms === 'Asset') {
        data[key] !== ''
          ? data[key] = this.assetMap[data[key]]
          : delete data[key]
      }
    })
  }

  /**
   * Performs necessary clean-up tasks
   * @param {Error|Boolean} error If param is truthy, extra error-related clean-up tasks are performed
   * @return {Promise}
   */
  async cleanUp (error) {
    try {
      const tasks = [
        fs.remove(this.unzipPath)
      ]
      if (error) {
        tasks.push(
          Promise.all(Object.values(this.newContentPlugins).map(p => this.contentplugin.uninstallPlugin(p._id))),
          Promise.all(Object.values(this.assetMap).map(a => this.assets.delete({ _id: a })))
        )
        let _courseId
        try {
          const { ObjectId } = await App.instance.waitForModule('mongodb')
          _courseId = ObjectId.parse(this.idMap[this.contentJson.course._id])
        } catch (e) {}
        if (_courseId) {
          tasks.push(
            this.content.deleteMany({ _courseId }),
            this.courseassets.deleteMany({ courseId: _courseId })
          )
        }
      }
      await Promise.allSettled(tasks)
    } catch (e) {} // ignore any thrown errors
  }
}

export default AdaptFrameworkImport