scormLib = new ScormLib(); } public function uploadScormArchive(UploadedFile $file, Model $model) { // Checks if it is a valid scorm archive $scormData = null; $zip = new ZipArchive(); $openValue = $zip->open($file); $oldModel = null; $isScormArchive = (true === $openValue) && $zip->getStream('imsmanifest.xml'); $zip->close(); if (!$isScormArchive) { throw new InvalidScormArchiveException('invalid_scorm_archive_message'); } else { $scormData = $this->generateScorm($file); } $oldModel = $model->scorm()->first(); // get old scorm data for deletion (If success to store new) // 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 = $model->scorm()->save($scorm); 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(); } } if ($oldModel != null) { $this->deleteScormData($oldModel); } } 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 ($scormVersionElements->length > 0) { 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; } public function deleteScormData($model) { // Delete after the previous item is stored if ($model) { $oldScos = $model->scos()->get(); // Delete all tracking associate with sco foreach ($oldScos as $oldSco) { $oldSco->scoTrackings()->delete(); } $model->scos()->delete(); // delete scos $model->delete(); // delete scorm // Delete folder from server $this->deleteScormFolder($model->hash_name); } } /** * @param $folderHashedName * @return bool */ protected function deleteScormFolder($folderHashedName) { $response = Storage::disk('scorm')->deleteDirectory($folderHashedName); return $response; } /** * 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'); if (substr($rootFolder, -1) != '/') { // If end with xxx/ $rootFolder = config('filesystems.disks.'.config('scorm.disk').'.root').'/'; } $destinationDir = $rootFolder.$hashName; // file path if (!File::isDirectory($destinationDir)) { File::makeDirectory($destinationDir, 0755, true, true); } $zip->extractTo($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'); if (substr($rootFolder, -1) != '/') { // If end with xxx/ $rootFolder = config('filesystems.disks.'.config('scorm.disk').'.root').'/'; } $destinationDir = $rootFolder.$hashName; // file path // Move Scorm archive in the files directory $finalFile = $file->move($destinationDir, $hashName.'.zip'); return [ 'name' => $hashFileName, // to follow standard file data format 'hashName' => $hashName, 'type' => $finalFile->getMimeType(), 'version' => $scormData['version'], 'scos' => $scormData['scos'], ]; } /** * Get SCO list * @param $scormId * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection */ public function getScos($scormId) { $scos = ScormScoModel::with([ 'scorm' ])->where('scorm_id', $scormId) ->get(); return $scos; } /** * Get sco by uuid * @param $scoUuid * @return null|\Illuminate\Database\Eloquent\Builder|Model */ public function getScoByUuid($scoUuid) { $sco = ScormScoModel::with([ 'scorm' ])->where('uuid', $scoUuid) ->firstOrFail(); return $sco; } public function getUserResult($scoId, $userId) { return ScormScoTrackingModel::where('sco_id', $scoId)->where('user_id', $userId)->first(); } 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->setUuid($storeTracking->uuid); $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(Carbon::parse($storeTracking->latest_date)); return $scoTracking; } public function findScoTrackingId($scoUuid, $scoTrackingUuid) { return ScormScoTrackingModel::with([ 'sco' ])->whereHas('sco', function (Builder $query) use ($scoUuid) { $query->where('uuid', $scoUuid); })->where('uuid', $scoTrackingUuid) ->firstOrFail(); } public function checkUserIsCompletedScorm($scormId, $userId) { $completedSco = []; $scos = ScormScoModel::where('scorm_id', $scormId)->get(); foreach ($scos as $sco) { $scoTracking = ScormScoTrackingModel::where('sco_id', $sco->id)->where('user_id', $userId)->first(); if ($scoTracking && ($scoTracking->lesson_status == 'passed' || $scoTracking->lesson_status == 'completed')) { $completedSco[] = true; } } if (count($completedSco) == $scos->count()) { return true; } else { return false; } } public function updateScoTracking($scoUuid, $userId, $data) { $tracking = $this->createScoTracking($scoUuid, $userId); $tracking->setLatestDate(Carbon::now()); $sco = $tracking->getSco(); $scorm = ScormModel::where('id', $sco['scorm_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: if (isset($data['cmi.suspend_data']) && !empty($data['cmi.suspend_data'])) { $tracking->setSuspendData($data['cmi.suspend_data']); } $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'] : 'unknown'; $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; $entry = isset($data['cmi.core.entry']) ? $data['cmi.core.entry'] : null; $exit = isset($data['cmi.core.exit']) ? $data['cmi.core.exit'] : null; $lessonLocation = isset($data['cmi.core.lesson_location']) ? $data['cmi.core.lesson_location'] : null; $totalTime = isset($data['cmi.core.total_time']) ? $data['cmi.core.total_time'] : 0; $tracking->setDetails($data); $tracking->setEntry($entry); $tracking->setExitMode($exit); $tracking->setLessonLocation($lessonLocation); $tracking->setSessionTime($sessionTimeInHundredth); // Compute total time $totalTimeInHundredth = $this->convertTimeInHundredth($totalTime); $totalTimeInHundredth += $sessionTimeInHundredth; // Persist total time if ($tracking->getTotalTimeInt() > 0) { $totalTimeInHundredth += $tracking->getTotalTimeInt(); } $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) && (int)$scoreRaw > (int)$bestScore)) { $tracking->setScoreRaw($scoreRaw); $tracking->setScoreMin($scoreMin); $tracking->setScoreMax($scoreMax); } 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']) && !empty($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) && (int)$scoreRaw > (int)$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(); $updateResult->progression = $tracking->getProgression(); $updateResult->score_raw = $tracking->getScoreRaw(); $updateResult->score_min = $tracking->getScoreMin(); $updateResult->score_max = $tracking->getScoreMax(); $updateResult->score_scaled = $tracking->getScoreScaled(); $updateResult->lesson_status = $tracking->getLessonStatus(); $updateResult->completion_status = $tracking->getCompletionStatus(); $updateResult->session_time = $tracking->getSessionTime(); $updateResult->total_time_int = $tracking->getTotalTimeInt(); $updateResult->total_time_string = $tracking->getTotalTimeString(); $updateResult->entry = $tracking->getEntry(); $updateResult->suspend_data = $tracking->getSuspendData(); $updateResult->exit_mode = $tracking->getExitMode(); $updateResult->credit = $tracking->getCredit(); $updateResult->lesson_location = $tracking->getLessonLocation(); $updateResult->lesson_mode = $tracking->getLessonMode(); $updateResult->is_locked = $tracking->getIsLocked(); $updateResult->details = $tracking->getDetails(); $updateResult->latest_date = $tracking->getLatestDate(); $updateResult->save(); 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; } }