adapt-authoring-coursetheme/lib/CourseThemeModule.js

import _ from 'lodash'
import AbstractApiModule from 'adapt-authoring-api'
import fs from 'fs/promises'
/**
 * Module which handles course theming
 * @memberof coursetheme
 * @extends {AbstractApiModule}
 */
class CourseThemeModule extends AbstractApiModule {
  /** @override */
  async setValues () {
    /** @ignore */ this.root = 'coursethemepresets'
    /** @ignore */ this.permissionsScope = 'content'
    /** @ignore */ this.schemaName = 'coursethemepreset'
    /** @ignore */ this.schemaExtensionName = 'coursethemepreset'
    /** @ignore */ this.collectionName = 'coursethemepresets'
    /** @ignore */ this.attributeKey = 'themeVariables'

    const perms = [`write:${this.permissionsScope}`]

    this.useDefaultRouteConfig()
    this.routes.push({
      route: '/:_id/apply/:courseId',
      validate: false,
      handlers: { post: this.applyHandler() },
      permissions: { post: perms },
      meta: {
        post: {
          summary: 'Apply theme preset to a course',
          responses: { 204: {} }
        }
      }
    }, {
      route: '/:_id/remove/:courseId',
      validate: false,
      handlers: { post: this.removeHandler() },
      permissions: { post: perms },
      meta: {
        post: {
          summary: 'Remove theme preset from a course',
          responses: { 204: {} }
        }
      }
    })
  }

  /** @override */
  async init () {
    await super.init()
    const [framework, content] = await this.app.waitForModule('adaptframework', 'content')
    /**
     * Cached module instance for easy access
     * @type {ContentModule}
     */
    this.content = content
    framework.preBuildHook.tap(this.writeCustomLess.bind(this))
  }

  /**
   * Writes the customStyle and themeVariables attributes to LESS files. themeVariables are reduced into a string of variables, in the format `@key: value;`
   * @param {AdaptFrameworkBuild} fwBuild Reference to the current build
   */
  async writeCustomLess (fwBuild) {
    const fontImportString = await this.processFileVariables(fwBuild)
    this.processCustomStyling(fwBuild)

    const { customStyle, [this.attributeKey]: variables, themeCustomStyle } = fwBuild.courseData.course.data
    return Promise.all([
      this.writeFile(fwBuild, '1-variables.less', fontImportString + this.getVariablesString(variables)),
      this.writeFile(fwBuild, '2-customStyles.less', customStyle),
      this.writeFile(fwBuild, '3-themeCustomStyles.less', themeCustomStyle)
    ])
  }

  /**
   * Generates a LESS variables string
   * @param {object} data The data to process
   * @param {string} variablesStr String memo to allow recursion
   * @return {string} The processed LESS varaibles string
   */
  getVariablesString (data = {}, variablesStr = '') {
    return Object.entries(data).reduce((s, [k, v]) => {
      if (_.isObject(v)) return this.getVariablesString(v, s)
      return v ? `${s}@${k}: ${v};\n` : s
    }, variablesStr)
  }

  /**
   * Writes a file to the theme folder
   * @param {Object} fwBuild Build data
   * @param {String} filename Name of output file
   * @param {String} fileContents Contents to be written
   * @return {Promise}
   */
  async writeFile (fwBuild, filename, fileContents) {
    if (_.isEmpty(fileContents)) {
      return
    }
    try {
      const outputDir = `${fwBuild.dir}/src/theme/${fwBuild.courseData.config.data._theme}/less/zzzzz`
      await fs.mkdir(outputDir, { recursive: true })
      await fs.writeFile(`${outputDir}/${filename}`, fileContents)
    } catch (e) {
      this.log('error', `failed to write ${filename}, ${e.message}`)
    }
  }

  /**
   * Handles applying theme settings
   * @return {Function} Handler function
   */
  applyHandler () {
    return async (req, res, next) => {
      try {
        const { _id, courseId } = req.apiData.query
        const [{ properties: presetProps }] = await this.find({ _id })
        await this.content.update({ _id: courseId }, { [this.attributeKey]: presetProps })
        res.sendStatus(204)
      } catch (e) {
        return next(e)
      }
    }
  }

  /**
   * Handles removing theme settings
   * @return {Function} Handler function
   */
  removeHandler () {
    return async (req, res, next) => {
      try {
        const { _id, courseId } = req.apiData.query
        const [{ properties: presetProps }] = await this.find({ _id })
        const [{ themeVariables: existingProps }] = await this.content.find({ _id: courseId })
        await this.content.update({ _id: courseId }, { [this.attributeKey]: _.pickBy(existingProps, (v, k) => v !== presetProps[k]) })
        res.sendStatus(204)
      } catch (e) {
        return next(e)
      }
    }
  }

  /**
   * Copies uploaded font files into the build
   * @param {object} data The data to process
   */
  async processFileVariables (fwBuild) {
    const assets = await this.app.waitForModule('assets')
    const fontData = fwBuild.courseData.course.data.themeVariables._font

    if (!fontData) {
      return ''
    }
    let fontImportCSS = ''
    // font files
    if (fontData._items) {
      await Promise.all(fontData._items.map(async f => {
        if (!f['font-family']) return
        // copy each uploaded font file
        for (const font in f._files) {
          const [data] = await assets.find({ _id: f._files[font] })
          const asset = assets.createFsWrapper(data)
          try {
            const relativeFontPath = `fonts/${f._files[font]}.${asset.data.subtype}`
            const absoluteFontPath = `${fwBuild.dir}/src/theme/${fwBuild.courseData.config.data._theme}/${relativeFontPath}`
            await fs.writeFile(absoluteFontPath, await asset.read())
            // construct font import styling
            fontImportCSS += `@font-face ${JSON.stringify({
              'font-family': `${f['font-family']};`,
              src: `url('./${relativeFontPath}') format('${asset.data.subtype}');`,
              'font-weight': `${font.includes('light') ? 'light' : font.includes('bold') ? 'bold' : 'normal'};`,
              'font-style': `${font.includes('Italic') ? 'italic' : 'normal'};`
            }, null, 2)}`
          } catch (e) {
            this.log('error', `failed to write ${f._files[font]}, ${e.message}`)
          }
        }
      }))
      delete fontData._items
    }
    // instruction style checkbox
    const _instruction = fontData._fontAssignments._instruction
    if (_instruction['instruction-font-style']) {
      _instruction['instruction-font-style'] = _instruction._isInstructionItalic ? 'italic' : 'normal'
      delete _instruction._isInstructionItalic
    }
    // external fonts
    if (fontData._externalFonts) {
      fontData._externalFonts.forEach(f => {
        if (f) fontImportCSS += `@import url('${f}');\n`
      })
      delete fontData._externalFonts
    }

    return fontImportCSS
  }

  processCustomStyling (fwBuild) {
    const customStyling = fwBuild.courseData.course.data.themeVariables.themeCustomStyle

    if (!customStyling) return

    fwBuild.courseData.course.data.themeCustomStyle = customStyling

    delete fwBuild.courseData.course.data.themeVariables.themeCustomStyle
  }
}

export default CourseThemeModule