services/scorm-service.js

/**
 * AngularJS Service for SCORM wrapper library.
 *
 * @module scormService
 * @see https://docs.angularjs.org/api/ng/type/angular.Module#service
 */
angular.module('tgaApp').service('scormService', ['$interval', '$window', '$log',
  function scormService($interval, $window, $log) {
    /**
     * Service for SCORM Wrapper Library.
     * https://github.com/pipwerks/scorm-api-wrapper
     *
     * @private
     * @todo Check whether this is actually used by anything.
     */
    this.scorm = undefined;

    /**
     * Internal storage for SCORM entry status.
     *
     * @private
     * @type {?string}
     */
    this.entryStatus = undefined;

    /**
     * Internal storage for SCORM service data.
     *
     * @private
     * @type {Object}
     */
    this.saved = {
      score: undefined,
      lesson_status: undefined,
      lmsConnected: undefined,
      user: {
        name: null,
        id: null,
      },
      version: undefined,
    };

    /**
     * Heartbeat interval (in milliseconds).
     *
     * @type {number}
     * @default
     */
    this.heartbeatDelay = 1500000;

    /**
     * Checks whether game was loaded in an iframe
     *
     * @returns {boolean}
     */
    this.inIframe = () => {
      try {
        return $window.self !== $window.top;
      } catch (e) {
        return true;
      }
    };

    /**
     * Initialize SCORM service and listeners.
     *
     * @listens window.message
     */
    this.init = () => {
      $log.debug('scorm-service init');

      $window.addEventListener('message', (event) => {
        // $log.debug(event.origin, event.currentTarget.location.origin);
        const { data } = event;

        // Make sure this is a SCORM message from the parent $window
        if (!this.inIframe() || !data.type || data.type !== 'scorm'
          || event.origin === event.currentTarget.location.origin) {
          return;
        }

        $log.debug('message received from SCORM: ', event);

        this.saved.version = data.version;
        switch (data.method) {
          case 'get':
            this.getHandler(data);
            break;
          case 'set':
            $log.debug(`set: ${data.model}: success: ${data.success}`);
            this.setHandler(data);
            break;
          case 'version':
            this.versionCallback();
            break;
          default:
            break;
        }
      }, false);

      this.getVersion();
    };

    /**
     * Handler for SCORM `version` message.
     *
     * @private
     */
    this.versionCallback = () => {
      this.getScore();
      this.getLessonStatus();
      this.getEntryStatus();
      this.getUser();
      this.startHeartbeat();
    };

    /**
     * Handler for SCORM `get` messages.
     *
     * @private
     * @param {Object} data - Message object.
     * @param {string} data.model - SCORM data model element.
     * @param {*} data.value - Value for SCORM element.
     */
    this.getHandler = (data) => {
      if (!this.saved.lmsConnected) {
        this.saved.lmsConnected = true;
      }
      switch (data.model) {
        case 'cmi.completion_status':
        case 'cmi.core.lesson_status':
          this.lessonStatusCallback(data);
          break;
        case 'cmi.score.raw':
        case 'cmi.core.score.raw':
          this.getScoreCallback(data);
          break;
        case 'cmi.learner_id':
        case 'cmi.core.student_id':
          this.saved.user.id = data.value;
          break;
        case 'cmi.learner_name':
        case 'cmi.core.student_name':
          this.saved.user.name = data.value;
          break;
        case 'cmi.entry':
        case 'cmi.core.entry':
          this.entryStatusCallback(data);
          break;
        default:
          break;
      }
    };

    /**
     * Check whether SCORM service is connected.
     *
     * @returns {boolean}
     */
    this.isConnected = () => this.saved.lmsConnected;

    /**
     * Get stored value from {@link module:scormService.entryStatus}
     * @return {?string}
     */
    this.provideEntryStatus = () => this.entryStatus;

    /**
     * Get stored SCORM `score`.
     *
     * @returns {?number}
     */
    this.provideSavedScore = () => this.saved.score;

    /**
     * Get stored SCORM `lesson_status`.
     *
     * @return {?string}
     */
    this.provideLessonStatus = () => this.saved.lesson_status;

    /**
     * Get stored SCORM `user`.
     *
     * @returns {Object}
     */
    this.provideUser = () => this.saved.user;

    /**
     * Handler for SCORM `set` messages.
     *
     * @private
     * @param {Object} data - Message object.
     * @param {string} data.model - SCORM data model element.
     * @param {*} data.value - Value for element.
     */
    this.setHandler = (data) => {
      if (data.success) {
        switch (data.model) {
          case 'cmi.completion_status':
          case 'cmi.core.lesson_status':
            this.saved.lesson_status = data.value;
            break;
          case 'cmi.score.raw':
          case 'cmi.core.score.raw':
            this.saved.score = data.value;
            this.save();
            break;
          default:
            break;
        }
      }
    };

    /**
     * Initialize scores in SCORM service.
     */
    this.initScores = () => {
      if (this.saved.version === '2004') {
        this.set('cmi.score.min', 0);
        this.set('cmi.score.max', 100);
      } else {
        this.set('cmi.core.score.min', 0);
        this.set('cmi.core.score.max', 100);
      }
    };

    /**
     * Sent `set` message for score to SCORM service.
     *
     * @param {number} number - The score value.
     */
    this.setScore = (number) => {
      if (this.saved.version === '2004') {
        this.set('cmi.score.raw', number);
        this.set('cmi.score.scaled', number / 100);
      } else {
        this.set('cmi.core.score.raw', number);
      }
    };

    /**
     * Send `get` message for score from SCORM service.
     *
     * @see {@link module:scormService.provideSavedScore}
     */
    this.getScore = () => {
      if (this.saved.version === '2004') {
        this.get('cmi.score.raw');
      } else {
        this.get('cmi.core.score.raw');
      }
    };

    /**
     * Callback for SCORM `get` message for score.
     * Used by {@link module:scormService.getHandler}
     *
     * @private
     * @param {Object} data - Message object.
     * @param {number} data.value - Score value.
     */
    this.getScoreCallback = (data) => {
      this.saved.score = Number(data.value);
    };

    /**
     * Send `get` message for lesson status from SCORM service.
     *
     * @see {@link module:scormService.provideLessonStatus}
     */
    this.getLessonStatus = () => {
      if (this.saved.version === '2004') {
        this.get('cmi.completion_status');
      } else {
        this.get('cmi.core.lesson_status');
      }
    };

    /**
     * Callback for SCORM `get` messages for lesson status.
     * Used by {@link module:scormService.getHandler}
     *
     * @private
     * @param {Object} data - Message object.
     * @param {string} data.value - Lesson status value.
     */
    this.lessonStatusCallback = (data) => {
      const status = data.value;
      this.lesson_status = status;
      $log.debug(status);
    };

    /**
     * Send `get` message for entry status from SCORM service.
     *
     * @see {@link module:scormService.provideEntryStatus}
     */
    this.getEntryStatus = () => {
      if (this.saved.version === '2004') {
        this.get('cmi.entry');
      } else {
        this.get('cmi.core.entry');
      }
    };

    /**
     * Callback for SCORM `get` messages for entry status.
     * Used by {@link module:scormService.getHandler}
     *
     * @private
     * @param {Object} data - Message object.
     * @param {string} data.value - Entry status value.
     */
    this.entryStatusCallback = (data) => {
      const status = data.value;
      $log.debug(status);
      if (status === 'ab_initio' || status === 'ab-initio') {
        $log.debug('first time, initialize scores');
        this.entryStatus = 'new';
        this.initScores();
        this.setCompletion(false);
      }
    };

    /**
     * Send `get` message for user from SCORM service.
     *
     * @see {@link module:scormService.provideUser}
     */
    this.getUser = () => {
      if (this.saved.version === '2004') {
        this.get('cmi.learner_name');
        this.get('cmi.learner_id');
      } else {
        this.get('cmi.core.student_name');
        this.get('cmi.core.student_id');
      }
    };

    /**
     * Check whether lesson status is completed.
     *
     * @returns {boolean}
     */
    this.checkCompletion = () => this.saved.lesson_status === 'completed';

    /**
     * Send `set` message for completion status to SCORM service.
     * @param {boolean} complete - The completion state.
     */
    this.setCompletion = function (complete) {
      const c_str = complete ? 'completed' : 'incomplete';

      if (this.saved.version === '2004') {
        this.set('cmi.completion_status', c_str);
        if (complete) {
          this.set('cmi.success_status', 'passed');
        }
      } else {
        this.set('cmi.core.lesson_status', c_str);
      }
    };

    /**
     * Start SCORM heartbeat.
     *
     * @see {@link module:scormService.heartbeatDelay}
     */
    this.startHeartbeat = () => {
      const func = () => {
        const msg = {
          type: 'scorm',
          method: 'heartbeat',
        };
        $window.parent.postMessage(msg, '*');
      };
      this.heartbeatInterval = $interval(func, this.heartbeatDelay);
    };

    /**
     * Stop SCORM heartbeat.
     */
    this.stopHeartbeat = () => {
      if (this.heartbeatInterval) {
        $interval.cancel(this.heartbeatInterval);
      }
    };

    /**
     * Send `version` message to SCORM service.
     */
    this.getVersion = () => {
      const msg = {
        type: 'scorm',
        method: 'version',
      };
      $window.parent.postMessage(msg, '*');
    };

    /**
     * Send `get` message for an arbitrary data model element from SCORM service.
     *
     * @param {string} model - SCORM data model element.
     */
    this.get = (model) => {
      const msg = {
        type: 'scorm',
        method: 'get',
        model,
      };
      $window.parent.postMessage(msg, '*');
    };

    /**
     * Send `set` message for an arbitrary data model element from SCORM service.
     *
     * @param {string} model - SCORM data model element.
     * @param {*} value - Value for SCORM element.
     */
    this.set = (model, value) => {
      const msg = {
        type: 'scorm',
        method: 'set',
        model,
        value,
      };
      $window.parent.postMessage(msg, '*');
    };

    /**
     * Calls `pipwerks.SCORM.data.save()` which calls `LMSCommit()`.
     * Instructs the LMS to persist all data to this point in the session.
     */
    this.save = () => {
      const msg = {
        type: 'scorm',
        method: 'save',
      };
      $window.parent.postMessage(msg, '*');
    };

    /**
     * Closes parent $window if parent $window is top most $window.
     * Closing $window triggers `pipwerks.SCORM.connection.terminate()`.
     */
    this.finish = () => {
      const msg = {
        type: 'scorm',
        method: 'finish',
      };
      $window.parent.postMessage(msg, '*');
    };
  },
]);