recognizer/websocket/iinkWsRecognizer.js

import uuid from 'uuid-js'
import { recognizerLogger as logger } from '../../configuration/LoggerConfig'
import Constants from '../../configuration/Constants'
import * as DefaultTheme from '../../configuration/DefaultTheme'
import * as DefaultPenStyle from '../../configuration/DefaultPenStyle'
import * as InkModel from '../../model/InkModel'
import * as RecognizerContext from '../../model/RecognizerContext'
import * as DefaultRecognizer from '../DefaultRecognizer'
import * as WsBuilder from './WsBuilder'
import * as WsRecognizerUtil from './WsRecognizerUtil'
import * as PromiseHelper from '../../util/PromiseHelper'
import { handleError, handleSuccess } from '../RecognizerService'

export { close } from './WsRecognizerUtil'

function readBlob (blob) {
  const fileReader = new FileReader()
  return new Promise((resolve, reject) => {
    fileReader.onload = event => resolve(event.target.result)
    fileReader.onerror = () => reject(new Error(this))
    fileReader.readAsText(blob)
  })
}

function getDPI (element) {
  // const startDpi = 56;
  // for (let dpi = startDpi; dpi < 2000; dpi++) {
  //   if (window.matchMedia(`(max-resolution: ${dpi}dpi)`).matches === true) {
  //     return dpi;
  //   }
  // }
  // return startDpi;
  return 96
}

/**
 * Recognizer configuration
 * @type {RecognizerInfo}
 */
export const IinkWsConfiguration = {
  types: [Constants.RecognitionType.MATH, Constants.RecognitionType.TEXT, Constants.RecognitionType.DIAGRAM],
  protocol: Constants.Protocol.WEBSOCKET,
  availableTriggers: {
    exportContent: [Constants.Trigger.POINTER_UP, Constants.Trigger.DEMAND],
    addStrokes: [Constants.Trigger.POINTER_UP]
  }
}

/**
 * Get the configuration supported by this recognizer
 * @return {RecognizerInfo}
 */
export function getInfo () {
  return IinkWsConfiguration
}

export function buildNewContentPackageInput (configuration, element) {
  return {
    type: 'newContentPackage',
    applicationKey: configuration.recognitionParams.server.applicationKey,
    xDpi: getDPI(element),
    yDpi: getDPI(element),
    viewSizeHeight: element.clientHeight < configuration.renderingParams.minHeight ? configuration.renderingParams.minHeight : element.clientHeight,
    viewSizeWidth: element.clientWidth < configuration.renderingParams.minWidth ? configuration.renderingParams.minWidth : element.clientWidth
  }
}

export function buildRestoreIInkSessionInput (configuration, element, sessionId) {
  return {
    type: 'restoreIInkSession',
    iinkSessionId: sessionId,
    applicationKey: configuration.recognitionParams.server.applicationKey,
    xDpi: getDPI(element),
    yDpi: getDPI(element),
    viewSizeHeight: element.clientHeight < configuration.renderingParams.minHeight ? configuration.renderingParams.minHeight : element.clientHeight,
    viewSizeWidth: element.clientWidth < configuration.renderingParams.minWidth ? configuration.renderingParams.minWidth : element.clientWidth
  }
}

export function buildNewContentPart (configuration) {
  return {
    type: 'newContentPart',
    contentType: configuration.recognitionParams.type,
    mimeTypes: (configuration.triggers.exportContent !== Constants.Trigger.DEMAND)
      ? configuration.recognitionParams.iink[`${configuration.recognitionParams.type.toLowerCase()}`].mimeTypes : undefined
  }
}

export function buildOpenContentPart (configuration, partId) {
  return {
    type: 'openContentPart',
    id: partId,
    mimeTypes: (configuration.triggers.exportContent !== Constants.Trigger.DEMAND)
      ? configuration.recognitionParams.iink[`${configuration.recognitionParams.type.toLowerCase()}`].mimeTypes : undefined
  }
}

export function buildConfiguration (configuration) {
  return Object.assign({ type: 'configuration' }, configuration.recognitionParams.iink)
}

function buildAddStrokes (recognizerContext, model) {
  const strokes = InkModel.extractPendingStrokes(model, recognizerContext.lastPositions.lastSentPosition + 1)
  if (strokes.length > 0) {
    InkModel.updateModelSentPosition(model)
    return {
      type: 'addStrokes',
      strokes: strokes.map(stroke => Object.assign({}, {
        id: stroke.id,
        pointerType: stroke.pointerType,
        pointerId: stroke.pointerId,
        x: stroke.x,
        y: stroke.y,
        t: stroke.t,
        p: stroke.p
      }))
    }
  }
  return undefined
}

function buildUndo () {
  return {
    type: 'undo'
  }
}

function buildRedo () {
  return {
    type: 'redo'
  }
}

function buildClear () {
  return {
    type: 'clear'
  }
}

function buildConvert (state) {
  return {
    type: 'convert',
    conversionState: state
  }
}

function buildZoom (value) {
  return {
    type: 'zoom',
    zoom: value
  }
}

function buildResize (element, minHeight = 0, minWidth = 0) {
  return {
    type: 'changeViewSize',
    height: element.clientHeight < minHeight ? minHeight : element.clientHeight,
    width: element.clientWidth < minWidth ? minWidth : element.clientWidth
  }
}

function buildExport (configuration, partId, requestedMimeType) {
  let usedMimeType
  if (requestedMimeType && Object.keys(requestedMimeType).length !== 0) {
    usedMimeType = requestedMimeType
  } else {
    usedMimeType = configuration.recognitionParams.iink[`${configuration.recognitionParams.type.toLowerCase()}`].mimeTypes
  }

  return {
    type: 'export',
    partId,
    mimeTypes: usedMimeType
  }
}

function buildImportFile (id, mimetype) {
  return {
    type: 'importFile',
    importFileId: id,
    mimeType: mimetype
  }
}

function buildImportChunk (id, data, lastChunk) {
  return {
    type: 'fileChunk',
    importFileId: id,
    data,
    lastChunk
  }
}

function buildPointerEvents (events) {
  return Object.assign({ type: 'pointerEvents' }, events)
}

function buildWaitForIdle () {
  return {
    type: 'waitForIdle'
  }
}

function buildGetSupportedImportMimeTypes () {
  return {
    type: 'getSupportedImportMimeTypes'
  }
}

export function buildSetPenStyle (penStyle) {
  return {
    type: 'setPenStyle',
    style: penStyle ? DefaultPenStyle.toCSS(penStyle) : ''
  }
}

export function buildSetPenStyleClasses (penStyleClasses) {
  return {
    type: 'setPenStyleClasses',
    styleClasses: penStyleClasses
  }
}

export function buildSetTheme (theme) {
  return {
    type: 'setTheme',
    theme: DefaultTheme.toCSS(theme)
  }
}

export const responseCallback = (model, err, res, recognizerContext) => {
  const modelReference = InkModel.updateModelReceivedPosition(model)
  if (res) {
    let event = ''
    if (res.updates !== undefined) {
      if (modelReference.recognizedSymbols) {
        modelReference.recognizedSymbols.push(res)
      } else {
        modelReference.recognizedSymbols = [res]
      }
      event = Constants.EventType.RENDERED
    }
    if (res.exports !== undefined) {
      modelReference.rawResults.exports = res
      modelReference.exports = res.exports
      event = Constants.EventType.EXPORTED
    }

    if ((res.canUndo !== undefined) || (res.canRedo !== undefined)) {
      event = Constants.EventType.CHANGED
    }

    if (res.type === 'supportedImportMimeTypes') {
      event = Constants.EventType.SUPPORTED_IMPORT_MIMETYPES
    }

    if (res.type === 'partChanged') {
      event = Constants.EventType.LOADED
    }

    if (res.type === 'idle') {
      event = Constants.EventType.IDLE
    }

    if (res.type === 'close') {
      event = Constants.EventType.CHANGED
    }
    return handleSuccess(recognizerContext.editor, model, event)
  }
  return handleError(recognizerContext.editor, err)
}

/**
 * Initialize recognition
 * The init process is in multiple part :
 * - partChange
 * - contentChange
 * - initPromise: resolved only if partChange & contentChange resolved except for MATH recognition
 *
 * @param {RecognizerContext} recognizerContext Current recognizer context
 * @param {Model} model Current model
 */
export async function init (recognizerContext, model) {
  const contentChange = PromiseHelper.destructurePromise()
  const partChange = PromiseHelper.destructurePromise()
  const initPromise = PromiseHelper.destructurePromise()

  let recognizerContextRef
  let contentChanged = null

  if (recognizerContext.editor.innerConfiguration.recognitionParams.type === 'MATH' ||
    recognizerContext.editor.innerConfiguration.recognitionParams.type === 'DIAGRAM') {
    recognizerContextRef = RecognizerContext.setRecognitionContext(recognizerContext, {
      model: InkModel.updateModelSentPosition(model, model.lastPositions.lastReceivedPosition),
      partChange,
      initPromise,
      patch: (err, res) => responseCallback(model, err, res, recognizerContextRef),
      error: (err, res) => responseCallback(model, err, res, recognizerContextRef)
    })
  } else {
    recognizerContextRef = RecognizerContext.setRecognitionContext(recognizerContext, {
      model: InkModel.updateModelSentPosition(model, model.lastPositions.lastReceivedPosition),
      contentChange,
      partChange,
      response: (err, res) => responseCallback(model, err, res, recognizerContextRef),
      initPromise,
      patch: (err, res) => responseCallback(model, err, res, recognizerContextRef),
      error: (err, res) => responseCallback(model, err, res, recognizerContextRef)
    })
    contentChanged = recognizerContextRef.recognitionContexts[0].contentChange.promise
  }

  WsRecognizerUtil.init('/api/v4.0/iink/document', recognizerContextRef, WsBuilder.buildWebSocketCallback, init)
    .catch(async (err) => {
      if (RecognizerContext.shouldAttemptImmediateReconnect(recognizerContext) && recognizerContext.reconnect) {
        logger.info('Attempting a reconnect', recognizerContext.currentReconnectionCount)
        await recognizerContext.reconnect(recognizerContext, model)
      } else {
        logger.error('Unable to reconnect', err)
        responseCallback(model, err, undefined, recognizerContext)
      }
    })

  const [errPartChanged, resPartChanged] = await recognizerContextRef.recognitionContexts[0].partChange.promise
  if (resPartChanged) {
    responseCallback(model, errPartChanged, resPartChanged, recognizerContext)
    if (contentChanged !== null) {
      const [errContentChanged, resContentChanged] = await contentChanged
      if (resContentChanged) {
        responseCallback(model, errContentChanged, resContentChanged, recognizerContext)
      }
    }
    recognizerContextRef.recognitionContexts[0].initPromise.resolve(true)
  }

  return recognizerContextRef.recognitionContexts[0].initPromise
}

/**
 *
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Function} buildFunction build the websocket message
 * @param {...Object} params spread parameters, will be passed to buildFunction
 * @private
 */
// eslint-disable-next-line no-underscore-dangle
async function _prepareMessage (recognizerContext, model, buildFunction, ...params) {
  logger.info(`-- Prepare message for ${buildFunction.name} --`)
  const contentChange = PromiseHelper.destructurePromise()
  const recognizerContextRef = RecognizerContext.setRecognitionContext(recognizerContext, {
    model,
    response: (err, res) => {
      const onDemand = recognizerContext.editor.configuration.triggers.exportContent === Constants.Trigger.DEMAND
      if (!onDemand || (onDemand && buildFunction.name === 'buildExport')) {
        responseCallback(model, err, res, recognizerContextRef)
      }
    },
    contentChange,
    patch: (err, res) => responseCallback(model, err, res, recognizerContextRef),
    error: (err, res) => responseCallback(model, err, res, recognizerContextRef)
  })
  WsRecognizerUtil.sendMessage(recognizerContextRef, buildFunction, ...params)
    .catch((err) => {
      logger.error(err)
      WsRecognizerUtil.retry(_prepareMessage, recognizerContext, model, buildFunction, ...params)
    })

  const contentChanged = await recognizerContextRef.recognitionContexts[0].contentChange.promise

  if (contentChanged) {
    responseCallback(model, contentChanged[0], contentChanged[1], recognizerContextRef)
    return {
      res: model,
      types: []
    }
  }

  return null
}

/**
 * Create a new content part
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function newContentPart (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildNewContentPart, recognizerContext.editor.configuration)
}

/**
 * Open the recognizer context content part
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function openContentPart (recognizerContext, model) {
  const params = [recognizerContext.editor.configuration, recognizerContext.currentPartId]
  return _prepareMessage(recognizerContext, model, buildOpenContentPart, params)
}

/**
 * Send the recognizer configuration
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function sendConfiguration (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildConfiguration, recognizerContext.editor.configuration)
}

/**
 * Pointer Events
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {PointerEvents} events to be imported
 */
export function pointerEvents (recognizerContext, model, events) {
  return _prepareMessage(recognizerContext, model, buildPointerEvents, events)
}

/**
 * Add strokes to the model
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function addStrokes (recognizerContext, model) {
  const params = [recognizerContext, model]
  return _prepareMessage(recognizerContext, model, buildAddStrokes, ...params)
}

/**
 * Undo last action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function undo (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildUndo)
}

/**
 * Redo last action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function redo (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildRedo)
}

/**
 * Clear action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export async function clear (recognizerContext, model) {
  const contentChange = PromiseHelper.destructurePromise()
  const recognizerContextRef = RecognizerContext.setRecognitionContext(recognizerContext, {
    model,
    response: (err, res) => {
      if (recognizerContext.editor.configuration.triggers.exportContent !== Constants.Trigger.DEMAND) {
        responseCallback(model, err, res, recognizerContextRef)
      }
    },
    contentChange,
    // eslint-disable-next-line handle-callback-err
    patch: async (error, result) => {
      const { err, res } = await DefaultRecognizer.clear(recognizerContext, model)
      responseCallback(res, err, result, recognizerContextRef)
    }
  })
  WsRecognizerUtil.sendMessage(recognizerContextRef, buildClear)
    .catch(exception => WsRecognizerUtil.retry(clear, recognizerContext, model))

  const contentChanged = await recognizerContextRef.recognitionContexts[0].contentChange.promise

  if (contentChanged) {
    responseCallback(model, contentChanged[0], contentChanged[1], recognizerContextRef)
    return {
      err: undefined,
      res: recognizerContextRef.recognitionContexts[0].model,
      events: []
    }
  }

  return null
}

/**
 * Convert action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {String} conversionState Conversion State, by default DigitalEdit
 */
export function convert (recognizerContext, model, conversionState) {
  return _prepareMessage(recognizerContext, model, buildConvert, conversionState)
}

/**
 * Export action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Array} requestedMimeTypes
 */
// eslint-disable-next-line no-underscore-dangle
export function export_ (recognizerContext, model, requestedMimeTypes) {
  const params = [recognizerContext.editor.configuration, recognizerContext.currentPartId, requestedMimeTypes]
  return _prepareMessage(recognizerContext, model, buildExport, ...params)
}

/**
 * Import action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Blob} data Import data
 */
// eslint-disable-next-line no-underscore-dangle
export function import_ (recognizerContext, model, data) {
  const recognitionContext = {
    model,
    response: (err, res) => responseCallback(model, err, res, recognizerContext),
    importFileId: uuid.create(4).toString()
  }
  const recognizerContextRef = RecognizerContext.setRecognitionContext(recognizerContext, recognitionContext)

  const chunkSize = recognizerContext.editor.configuration.recognitionParams.server.websocket.fileChunkSize

  const messages = []
  for (let i = 0; i < data.size; i += chunkSize) {
    if (i === 0) {
      messages.push(_prepareMessage(recognizerContextRef, model, buildImportFile, recognitionContext.importFileId, data.type))
    }
    const blobPart = data.slice(i, chunkSize, data.type)
    readBlob(blobPart).then((res) => {
      const params = [recognitionContext.importFileId, res, i + chunkSize > data.size]
      messages.push(_prepareMessage(recognizerContextRef, model, buildImportChunk, ...params))
    })
  }

  return Promise.all(messages)
}

/**
 * Ask for the supported mimetypes
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function getSupportedImportMimeTypes (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildGetSupportedImportMimeTypes)
}

/**
 * WaitForIdle action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 */
export function waitForIdle (recognizerContext, model) {
  return _prepareMessage(recognizerContext, model, buildWaitForIdle)
}

/**
 * Resize
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Element} element Current element
 */
export function resize (recognizerContext, model, element) {
  const params = [element, recognizerContext.editor.configuration.renderingParams.minHeight, recognizerContext.editor.configuration.renderingParams.minWidth]
  return _prepareMessage(recognizerContext, model, buildResize, ...params)
}

/**
 * Zoom action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Number} value=10 Zoom value
 */
export function zoom (recognizerContext, model, value = 10) {
  return _prepareMessage(recognizerContext, model, buildZoom, value)
}

/**
 * SetPenStyle action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {PenStyle} penStyle Current penStyle
 */
export function setPenStyle (recognizerContext, model, penStyle) {
  return _prepareMessage(recognizerContext, model, buildSetPenStyle, penStyle)
}

/**
 * setPenStyleClasses action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {String} penStyleClasses Current penStyleClasses
 */
export function setPenStyleClasses (recognizerContext, model, penStyleClasses) {
  return _prepareMessage(recognizerContext, model, buildSetPenStyleClasses, penStyleClasses)
}

/**
 * SetTheme action
 * @param {RecognizerContext} recognizerContext Current recognition context
 * @param {Model} model Current model
 * @param {Theme} theme Current theme
 */
export function setTheme (recognizerContext, model, theme) {
  return _prepareMessage(recognizerContext, model, buildSetTheme, theme)
}