diff options
author | Devian <devianleong@gmail.com> | 2021-04-22 17:03:46 +0800 |
---|---|---|
committer | Devian <devianleong@gmail.com> | 2021-04-22 17:03:46 +0800 |
commit | 745cf2431a71d0e6c5f08f8605839279b2f7496e (patch) | |
tree | 11e4c7a19ac9f9efc1bb253b29b1fa488c34238e /src/Manager |
Initiate commit
Diffstat (limited to 'src/Manager')
-rw-r--r-- | src/Manager/ScormManager.php | 587 |
1 files changed, 587 insertions, 0 deletions
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; + } +} |