From 745cf2431a71d0e6c5f08f8605839279b2f7496e Mon Sep 17 00:00:00 2001
From: Devian <devianleong@gmail.com>
Date: Thu, 22 Apr 2021 17:03:46 +0800
Subject: Initiate commit

---
 src/Manager/ScormManager.php | 587 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 587 insertions(+)
 create mode 100644 src/Manager/ScormManager.php

(limited to 'src/Manager')

diff --git a/src/Manager/ScormManager.php b/src/Manager/ScormManager.php
new file mode 100644
index 0000000..1dfbc05
--- /dev/null
+++ b/src/Manager/ScormManager.php
@@ -0,0 +1,587 @@
+<?php
+
+
+namespace Peopleaps\Scorm\Manager;
+
+use App\Models\User;
+use Carbon\Carbon;
+use DOMDocument;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Storage;
+use League\Flysystem\FileNotFoundException;
+use Peopleaps\Scorm\Entity\Sco;
+use Peopleaps\Scorm\Entity\Scorm;
+use Peopleaps\Scorm\Entity\ScoTracking;
+use Peopleaps\Scorm\Exception\InvalidScormArchiveException;
+use Peopleaps\Scorm\Exception\StorageNotFoundException;
+use Peopleaps\Scorm\Library\ScormLib;
+use Peopleaps\Scorm\Model\ScormModel;
+use Peopleaps\Scorm\Model\ScormScoModel;
+use Peopleaps\Scorm\Model\ScormScoTrackingModel;
+use Ramsey\Uuid\Uuid;
+use ZipArchive;
+
+class ScormManager
+{
+    /** @var ScormLib */
+    private $scormLib;
+
+    /**
+     * Constructor.
+     *
+     * @param string $filesDir
+     * @param string $uploadDir
+     */
+    public function __construct(
+    ) {
+        $this->scormLib = new ScormLib();
+    }
+
+    public function uploadScormArchive(UploadedFile $file)
+    {
+        // Checks if it is a valid scorm archive
+        $scormData  =   null;
+        $zip = new ZipArchive();
+        $openValue = $zip->open($file);
+
+        $isScormArchive = (true === $openValue) && $zip->getStream('imsmanifest.xml');
+
+        $zip->close();
+
+        if (!$isScormArchive) {
+            throw new InvalidScormArchiveException('invalid_scorm_archive_message');
+        } else {
+            $scormData  =   $this->generateScorm($file);
+        }
+
+        // save to db
+        if ($scormData && is_array($scormData)) {
+            $scorm  =   new ScormModel();
+            $scorm->version =   $scormData['version'];
+            $scorm->hash_name =   $scormData['hashName'];
+            $scorm->origin_file =   $scormData['name'];
+            $scorm->origin_file_mime =   $scormData['type'];
+            $scorm->uuid =   $scormData['hashName'];
+            $scorm->save();
+
+            if (!empty($scormData['scos']) && is_array($scormData['scos'])) {
+                foreach ($scormData['scos'] as $scoData) {
+
+                    $scoParent    =   null;
+                    if (!empty($scoData->scoParent)) {
+                        $scoParent    =   ScormScoModel::where('uuid', $scoData->scoParent->uuid)->first();
+                    }
+
+                    $sco    =   new ScormScoModel();
+                    $sco->scorm_id  =   $scorm->id;
+                    $sco->uuid  =   $scoData->uuid;
+                    $sco->sco_parent_id  =   $scoParent ? $scoParent->id : null;
+                    $sco->entry_url  =   $scoData->entryUrl;
+                    $sco->identifier  =   $scoData->identifier;
+                    $sco->title  =   $scoData->title;
+                    $sco->visible  =   $scoData->visible;
+                    $sco->sco_parameters  =   $scoData->parameters;
+                    $sco->launch_data  =   $scoData->launchData;
+                    $sco->max_time_allowed  =   $scoData->maxTimeAllowed;
+                    $sco->time_limit_action  =   $scoData->timeLimitAction;
+                    $sco->block  =   $scoData->block;
+                    $sco->score_int  =   $scoData->scoreToPassInt;
+                    $sco->score_decimal  =   $scoData->scoreToPassDecimal;
+                    $sco->completion_threshold  =   $scoData->completionThreshold;
+                    $sco->prerequisites  =   $scoData->prerequisites;
+                    $sco->save();
+                }
+            }
+        }
+
+        return  $scormData;
+    }
+
+    private function parseScormArchive(UploadedFile $file)
+    {
+        $data = [];
+        $contents = '';
+        $zip = new \ZipArchive();
+
+        $zip->open($file);
+        $stream = $zip->getStream('imsmanifest.xml');
+
+        while (!feof($stream)) {
+            $contents .= fread($stream, 2);
+        }
+
+
+        $dom = new DOMDocument();
+
+        if (!$dom->loadXML($contents)) {
+            throw new InvalidScormArchiveException('cannot_load_imsmanifest_message');
+        }
+
+        $scormVersionElements = $dom->getElementsByTagName('schemaversion');
+
+        if (1 === $scormVersionElements->length) {
+            switch ($scormVersionElements->item(0)->textContent) {
+                case '1.2':
+                    $data['version'] = Scorm::SCORM_12;
+                    break;
+                case 'CAM 1.3':
+                case '2004 3rd Edition':
+                case '2004 4th Edition':
+                    $data['version'] = Scorm::SCORM_2004;
+                    break;
+                default:
+                    throw new InvalidScormArchiveException('invalid_scorm_version_message');
+            }
+        } else {
+            throw new InvalidScormArchiveException('invalid_scorm_version_message');
+        }
+        $scos = $this->scormLib->parseOrganizationsNode($dom);
+
+        if (0 >= count($scos)) {
+            throw new InvalidScormArchiveException('no_sco_in_scorm_archive_message');
+        }
+        $data['scos'] = $scos;
+
+        return $data;
+    }
+
+    /**
+     * Unzip a given ZIP file into the web resources directory.
+     *
+     * @param string $hashName name of the destination directory
+     */
+    private function unzipScormArchive(UploadedFile $file, $hashName)
+    {
+        $zip = new \ZipArchive();
+        $zip->open($file);
+
+        if (!config()->has('filesystems.disks.'.config('scorm.disk').'.root')) {
+            throw new StorageNotFoundException();
+        }
+
+        $rootFolder =   config('filesystems.disks.'.config('scorm.disk').'.root').'/';
+        $destinationDir = config('scorm.upload_path').$hashName; // file path
+
+        if (!File::isDirectory($rootFolder.$destinationDir)) {
+            File::makeDirectory($rootFolder.$destinationDir, 0755, true, true);
+        }
+
+        $zip->extractTo($rootFolder.$destinationDir);
+        $zip->close();
+    }
+
+    /**
+     * @param UploadedFile $file
+     * @return array
+     * @throws InvalidScormArchiveException
+     */
+    private function generateScorm(UploadedFile $file)
+    {
+        $hashName = Uuid::uuid4();
+        $hashFileName = $hashName.'.zip';
+        $scormData = $this->parseScormArchive($file);
+        $this->unzipScormArchive($file, $hashName);
+
+        if (!config()->has('filesystems.disks.'.config('scorm.disk').'.root')) {
+            throw new StorageNotFoundException();
+        }
+
+        $rootFolder =   config('filesystems.disks.'.config('scorm.disk').'.root').'/';
+        $destinationDir = config('scorm.upload_path').$hashName; // file path
+
+        // Move Scorm archive in the files directory
+        $finalFile = $file->move($rootFolder.$destinationDir, $hashName.'.zip');
+
+        return [
+            'name' => $hashFileName, // to follow standard file data format
+            'hashName' => $hashName,
+            'type' => $finalFile->getMimeType(),
+            'version' => $scormData['version'],
+            'scos' => $scormData['scos'],
+        ];
+    }
+
+    public function createScoTracking($scoUuid, $userId = null)
+    {
+        $sco    =   ScormScoModel::where('uuid', $scoUuid)->firstOrFail();
+
+        $version = $sco->scorm->version;
+        $scoTracking = new ScoTracking();
+        $scoTracking->setSco($sco->toArray());
+
+        switch ($version) {
+            case Scorm::SCORM_12:
+                $scoTracking->setLessonStatus('not attempted');
+                $scoTracking->setSuspendData('');
+                $scoTracking->setEntry('ab-initio');
+                $scoTracking->setLessonLocation('');
+                $scoTracking->setCredit('no-credit');
+                $scoTracking->setTotalTimeInt(0);
+                $scoTracking->setSessionTime(0);
+                $scoTracking->setLessonMode('normal');
+                $scoTracking->setExitMode('');
+
+                if (is_null($sco->prerequisites)) {
+                    $scoTracking->setIsLocked(false);
+                } else {
+                    $scoTracking->setIsLocked(true);
+                }
+                break;
+            case Scorm::SCORM_2004:
+                $scoTracking->setTotalTimeString('PT0S');
+                $scoTracking->setCompletionStatus('unknown');
+                $scoTracking->setLessonStatus('unknown');
+                $scoTracking->setIsLocked(false);
+                break;
+        }
+
+        $scoTracking->setUserId($userId);
+
+        // Create a new tracking model
+        $storeTracking  =   ScormScoTrackingModel::firstOrCreate([
+            'user_id'   =>  $userId,
+            'sco_id'    =>  $sco->id
+        ], [
+            'uuid'  =>  Uuid::uuid4(),
+            'progression'  =>  $scoTracking->getProgression(),
+            'score_raw'  =>  $scoTracking->getScoreRaw(),
+            'score_min'  =>  $scoTracking->getScoreMin(),
+            'score_max'  =>  $scoTracking->getScoreMax(),
+            'score_scaled'  =>  $scoTracking->getScoreScaled(),
+            'lesson_status'  =>  $scoTracking->getLessonStatus(),
+            'completion_status'  =>  $scoTracking->getCompletionStatus(),
+            'session_time'  =>  $scoTracking->getSessionTime(),
+            'total_time_int'  =>  $scoTracking->getTotalTimeInt(),
+            'total_time_string'  =>  $scoTracking->getTotalTimeString(),
+            'entry'  =>  $scoTracking->getEntry(),
+            'suspend_data'  =>  $scoTracking->getSuspendData(),
+            'credit'  =>  $scoTracking->getCredit(),
+            'exit_mode'  =>  $scoTracking->getExitMode(),
+            'lesson_location'  =>  $scoTracking->getLessonLocation(),
+            'lesson_mode'  =>  $scoTracking->getLessonMode(),
+            'is_locked'  =>  $scoTracking->getIsLocked(),
+            'details'  =>  $scoTracking->getDetails(),
+            'latest_date'  =>  $scoTracking->getLatestDate(),
+            'created_at'  =>  Carbon::now(),
+            'updated_at'  =>  Carbon::now(),
+        ]);
+
+        $scoTracking->setProgression($storeTracking->progression);
+        $scoTracking->setScoreRaw($storeTracking->score_raw);
+        $scoTracking->setScoreMin($storeTracking->score_min);
+        $scoTracking->setScoreMax($storeTracking->score_max);
+        $scoTracking->setScoreScaled($storeTracking->score_scaled);
+        $scoTracking->setLessonStatus($storeTracking->lesson_status);
+        $scoTracking->setCompletionStatus($storeTracking->completion_status);
+        $scoTracking->setSessionTime($storeTracking->session_time);
+        $scoTracking->setTotalTimeInt($storeTracking->total_time_int);
+        $scoTracking->setTotalTimeString($storeTracking->total_time_string);
+        $scoTracking->setEntry($storeTracking->entry);
+        $scoTracking->setSuspendData($storeTracking->suspend_data);
+        $scoTracking->setCredit($storeTracking->credit);
+        $scoTracking->setExitMode($storeTracking->exit_mode);
+        $scoTracking->setLessonLocation($storeTracking->lesson_location);
+        $scoTracking->setLessonMode($storeTracking->lesson_mode);
+        $scoTracking->setIsLocked($storeTracking->is_locked);
+        $scoTracking->setDetails($storeTracking->details);
+        $scoTracking->setLatestDate($storeTracking->latest_date);
+
+        return $storeTracking;
+    }
+
+    public function updateScoTracking($scoUuid, $userId, $data)
+    {
+        $tracking = $this->createScoTracking($scoUuid, $userId);
+        $tracking->setLatestDate(new \DateTime());
+        $sco    =   $tracking->getSco();
+        $scorm  =   ScormModel::where('id', $sco['id'])->firstOrFail();
+
+        $statusPriority = [
+            'unknown' => 0,
+            'not attempted' => 1,
+            'browsed' => 2,
+            'incomplete' => 3,
+            'completed' => 4,
+            'failed' => 5,
+            'passed' => 6,
+        ];
+
+        switch ($scorm->version) {
+            case Scorm::SCORM_12:
+                $scoreRaw = isset($data['cmi.core.score.raw']) ? intval($data['cmi.core.score.raw']) : null;
+                $scoreMin = isset($data['cmi.core.score.min']) ? intval($data['cmi.core.score.min']) : null;
+                $scoreMax = isset($data['cmi.core.score.max']) ? intval($data['cmi.core.score.max']) : null;
+                $lessonStatus = isset($data['cmi.core.lesson_status']) ? $data['cmi.core.lesson_status'] : null;
+                $sessionTime = isset($data['cmi.core.session_time']) ? $data['cmi.core.session_time'] : null;
+                $sessionTimeInHundredth = $this->convertTimeInHundredth($sessionTime);
+                $progression = isset($data['cmi.progress_measure']) ? floatval($data['cmi.progress_measure']) : 0;
+
+                $tracking->setDetails($data);
+                $tracking->setEntry($data['cmi.core.entry']);
+                $tracking->setExitMode($data['cmi.core.exit']);
+                $tracking->setLessonLocation($data['cmi.core.lesson_location']);
+                $tracking->setSessionTime($sessionTimeInHundredth);
+                $tracking->setSuspendData($data['cmi.suspend_data']);
+
+                // Compute total time
+                $totalTimeInHundredth = $this->convertTimeInHundredth($data['cmi.core.total_time']);
+                $totalTimeInHundredth += $sessionTimeInHundredth;
+
+                // Persist total time
+                $tracking->setTotalTime($totalTimeInHundredth, Scorm::SCORM_12);
+
+                $bestScore = $tracking->getScoreRaw();
+                $bestStatus = $tracking->getLessonStatus();
+
+                // Update best score if the current score is better than the previous best score
+                if (empty($bestScore) || (!is_null($scoreRaw) && $scoreRaw > $bestScore)) {
+                    $tracking->setScoreRaw($scoreRaw);
+                    $bestScore = $scoreRaw;
+                }
+
+                if (empty($bestStatus) || ($lessonStatus !== $bestStatus && $statusPriority[$lessonStatus] > $statusPriority[$bestStatus])) {
+                    $tracking->setLessonStatus($lessonStatus);
+                    $bestStatus = $lessonStatus;
+                }
+
+                if (empty($progression) && ('completed' === $bestStatus || 'passed' === $bestStatus)) {
+                    $progression = 100;
+                }
+
+                if ($progression > $tracking->getProgression()) {
+                    $tracking->setProgression($progression);
+                }
+
+                break;
+
+            case Scorm::SCORM_2004:
+                $tracking->setDetails($data);
+
+                if (isset($data['cmi.suspend_data'])) {
+                    $tracking->setSuspendData($data['cmi.suspend_data']);
+                }
+
+                $dataSessionTime = isset($data['cmi.session_time']) ?
+                    $this->formatSessionTime($data['cmi.session_time']) :
+                    'PT0S';
+                $completionStatus = isset($data['cmi.completion_status']) ? $data['cmi.completion_status'] : 'unknown';
+                $successStatus = isset($data['cmi.success_status']) ? $data['cmi.success_status'] : 'unknown';
+                $scoreRaw = isset($data['cmi.score.raw']) ? intval($data['cmi.score.raw']) : null;
+                $scoreMin = isset($data['cmi.score.min']) ? intval($data['cmi.score.min']) : null;
+                $scoreMax = isset($data['cmi.score.max']) ? intval($data['cmi.score.max']) : null;
+                $scoreScaled = isset($data['cmi.score.scaled']) ? floatval($data['cmi.score.scaled']) : null;
+                $progression = isset($data['cmi.progress_measure']) ? floatval($data['cmi.progress_measure']) : 0;
+                $bestScore = $tracking->getScoreRaw();
+
+                // Computes total time
+                $totalTime = new \DateInterval($tracking->getTotalTimeString());
+
+                try {
+                    $sessionTime = new \DateInterval($dataSessionTime);
+                } catch (\Exception $e) {
+                    $sessionTime = new \DateInterval('PT0S');
+                }
+                $computedTime = new \DateTime();
+                $computedTime->setTimestamp(0);
+                $computedTime->add($totalTime);
+                $computedTime->add($sessionTime);
+                $computedTimeInSecond = $computedTime->getTimestamp();
+                $totalTimeInterval = $this->retrieveIntervalFromSeconds($computedTimeInSecond);
+                $data['cmi.total_time'] = $totalTimeInterval;
+                $tracking->setTotalTimeString($totalTimeInterval);
+
+                // Update best score if the current score is better than the previous best score
+                if (empty($bestScore) || (!is_null($scoreRaw) && $scoreRaw > $bestScore)) {
+                    $tracking->setScoreRaw($scoreRaw);
+                    $tracking->setScoreMin($scoreMin);
+                    $tracking->setScoreMax($scoreMax);
+                    $tracking->setScoreScaled($scoreScaled);
+                }
+
+                // Update best success status and completion status
+                $lessonStatus = $completionStatus;
+                if (in_array($successStatus, ['passed', 'failed'])) {
+                    $lessonStatus = $successStatus;
+                }
+
+                $bestStatus = $tracking->getLessonStatus();
+                if (empty($bestStatus) || ($lessonStatus !== $bestStatus && $statusPriority[$lessonStatus] > $statusPriority[$bestStatus])) {
+                    $tracking->setLessonStatus($lessonStatus);
+                    $bestStatus = $lessonStatus;
+                }
+
+                if (empty($tracking->getCompletionStatus())
+                    || ($completionStatus !== $tracking->getCompletionStatus() && $statusPriority[$completionStatus] > $statusPriority[$tracking->getCompletionStatus()])
+                ) {
+                    // This is no longer needed as completionStatus and successStatus are merged together
+                    // I keep it for now for possible retro compatibility
+                    $tracking->setCompletionStatus($completionStatus);
+                }
+
+                if (empty($progression) && ('completed' === $bestStatus || 'passed' === $bestStatus)) {
+                    $progression = 100;
+                }
+
+                if ($progression > $tracking->getProgression()) {
+                    $tracking->setProgression($progression);
+                }
+
+                break;
+        }
+
+        $updateResult   =   ScormScoTrackingModel::where('user_id', $tracking->getUserId())
+            ->where('sco_id', $sco['id'])
+            ->firstOrFail();
+
+        if ($tracking->getProgression()) {
+            $updateResult->progression  =   $tracking->getProgression();
+        }
+
+        if ($tracking->getScoreRaw()) {
+            $updateResult->score_raw    =   $tracking->getScoreRaw();
+        }
+
+        if ($tracking->getScoreMin()) {
+            $updateResult->score_min    =   $tracking->getScoreMin();
+        }
+
+        if ($tracking->getScoreMax()) {
+            $updateResult->score_max    =   $tracking->getScoreMax();
+        }
+
+        if ($tracking->getScoreScaled()) {
+            $updateResult->score_scaled    =   $tracking->getScoreScaled();
+        }
+
+        if ($tracking->getLessonStatus()) {
+            $updateResult->lesson_status    =   $tracking->getLessonStatus();
+        }
+
+        if ($tracking->getCompletionStatus()) {
+            $updateResult->completion_status    =   $tracking->getCompletionStatus();
+        }
+
+        if ($tracking->getSessionTime()) {
+            $updateResult->session_time    =   $tracking->getSessionTime();
+        }
+
+        if ($tracking->getTotalTimeInt()) {
+            $updateResult->total_time_int    =   $tracking->getTotalTimeInt();
+        }
+
+        if ($tracking->getTotalTimeString()) {
+            $updateResult->total_time_string    =   $tracking->getTotalTimeString();
+        }
+
+        if ($tracking->getEntry()) {
+            $updateResult->entry    =   $tracking->getEntry();
+        }
+
+        if ($tracking->getSuspendData()) {
+            $updateResult->suspend_data    =   $tracking->getSuspendData();
+        }
+
+        if ($tracking->getCredit()) {
+            $updateResult->credit    =   $tracking->getCredit();
+        }
+
+        if ($tracking->getExitMode()) {
+            $updateResult->exit_mode    =   $tracking->getExitMode();
+        }
+
+        if ($tracking->getLessonLocation()) {
+            $updateResult->lesson_location    =   $tracking->getLessonLocation();
+        }
+
+        if ($tracking->getLessonMode()) {
+            $updateResult->lesson_mode    =   $tracking->getLessonMode();
+        }
+
+        if ($tracking->getIsLocked()) {
+            $updateResult->is_locked    =   $tracking->getIsLocked();
+        }
+
+        if ($tracking->getDetails()) {
+            $updateResult->details    =   $tracking->getDetails();
+        }
+
+        if ($tracking->getLatestDate()) {
+            $updateResult->latest_date    =   $tracking->getLatestDate();
+        }
+
+        $updateResult->save();
+
+//        $this->om->persist($tracking);
+//        $this->om->flush();
+
+        return $updateResult;
+    }
+
+    private function convertTimeInHundredth($time)
+    {
+        if ($time != null) {
+            $timeInArray = explode(':', $time);
+            $timeInArraySec = explode('.', $timeInArray[2]);
+            $timeInHundredth = 0;
+
+            if (isset($timeInArraySec[1])) {
+                if (1 === strlen($timeInArraySec[1])) {
+                    $timeInArraySec[1] .= '0';
+                }
+                $timeInHundredth = intval($timeInArraySec[1]);
+            }
+            $timeInHundredth += intval($timeInArraySec[0]) * 100;
+            $timeInHundredth += intval($timeInArray[1]) * 6000;
+            $timeInHundredth += intval($timeInArray[0]) * 360000;
+
+            return $timeInHundredth;
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Converts a time in seconds to a DateInterval string.
+     *
+     * @param int $seconds
+     *
+     * @return string
+     */
+    private function retrieveIntervalFromSeconds($seconds)
+    {
+        $result = '';
+        $remainingTime = (int) $seconds;
+
+        if (empty($remainingTime)) {
+            $result .= 'PT0S';
+        } else {
+            $nbDays = (int) ($remainingTime / 86400);
+            $remainingTime %= 86400;
+            $nbHours = (int) ($remainingTime / 3600);
+            $remainingTime %= 3600;
+            $nbMinutes = (int) ($remainingTime / 60);
+            $nbSeconds = $remainingTime % 60;
+            $result .= 'P'.$nbDays.'DT'.$nbHours.'H'.$nbMinutes.'M'.$nbSeconds.'S';
+        }
+
+        return $result;
+    }
+
+    private function formatSessionTime($sessionTime)
+    {
+        $formattedValue = 'PT0S';
+        $generalPattern = '/^P([0-9]+Y)?([0-9]+M)?([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+S)?$/';
+        $decimalPattern = '/^P([0-9]+Y)?([0-9]+M)?([0-9]+D)?T([0-9]+H)?([0-9]+M)?[0-9]+\.[0-9]{1,2}S$/';
+
+        if ('PT' !== $sessionTime) {
+            if (preg_match($generalPattern, $sessionTime)) {
+                $formattedValue = $sessionTime;
+            } elseif (preg_match($decimalPattern, $sessionTime)) {
+                $formattedValue = preg_replace(['/\.[0-9]+S$/'], ['S'], $sessionTime);
+            }
+        }
+
+        return $formattedValue;
+    }
+}
-- 
cgit v1.2.3