diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Entity/Sco.php | 270 | ||||
-rw-r--r-- | src/Entity/ScoTracking.php | 343 | ||||
-rw-r--r-- | src/Entity/Scorm.php | 146 | ||||
-rw-r--r-- | src/Exception/InvalidScormArchiveException.php | 11 | ||||
-rw-r--r-- | src/Exception/StorageNotFoundException.php | 11 | ||||
-rw-r--r-- | src/Facade/ScormManager.php | 14 | ||||
-rw-r--r-- | src/Library/ScormLib.php | 253 | ||||
-rw-r--r-- | src/Manager/ScormManager.php | 587 | ||||
-rw-r--r-- | src/Model/ScormModel.php | 19 | ||||
-rw-r--r-- | src/Model/ScormScoModel.php | 19 | ||||
-rw-r--r-- | src/Model/ScormScoTrackingModel.php | 42 | ||||
-rw-r--r-- | src/ScormServiceProvider.php | 62 |
12 files changed, 1777 insertions, 0 deletions
diff --git a/src/Entity/Sco.php b/src/Entity/Sco.php new file mode 100644 index 0000000..75eec2b --- /dev/null +++ b/src/Entity/Sco.php @@ -0,0 +1,270 @@ +<?php + + +namespace Peopleaps\Scorm\Entity; + + +class Sco +{ + public $id; + public $uuid; + public $scorm; + public $scoParent; + public $scoChildren; + public $entryUrl; + public $identifier; + public $title; + public $visible; + public $parameters; + public $launchData; + public $maxTimeAllowed; + public $timeLimitAction; + public $block; + public $scoreToPassInt; + public $scoreToPassDecimal; + public $completionThreshold; + public $prerequisites; + + public function getData() { + return [ + 'id' => $this->getId(), + 'scorm' => $this->getScorm(), + 'scoParent' => $this->getScoParent(), + 'entryUrl' => $this->getEntryUrl(), + 'identifier' => $this->getIdentifier(), + + ]; + } + + public function getUuid() + { + return $this->uuid; + } + + public function setUuid($uuid) + { + $this->uuid = $uuid; + } + + public function getId() + { + return $this->id; + } + + public function setId($id) + { + $this->id = $id; + } + + public function getScorm() + { + return $this->scorm; + } + + public function setScorm(Scorm $scorm = null) + { + $this->scorm = $scorm; + } + + public function getScoParent() + { + return $this->scoParent; + } + + public function setScoParent(Sco $scoParent = null) + { + $this->scoParent = $scoParent; + } + + public function getScoChildren() + { + return $this->scoChildren; + } + + public function setScoChildren($scoChildren) + { + $this->scoChildren = $scoChildren; + } + + public function getEntryUrl() + { + return $this->entryUrl; + } + + public function setEntryUrl($entryUrl) + { + $this->entryUrl = $entryUrl; + } + + public function getIdentifier() + { + return $this->identifier; + } + + public function setIdentifier($identifier) + { + $this->identifier = $identifier; + } + + public function getTitle() + { + return $this->title; + } + + public function setTitle($title) + { + $this->title = $title; + } + + public function isVisible() + { + return $this->visible; + } + + public function setVisible($visible) + { + $this->visible = $visible; + } + + public function getParameters() + { + return $this->parameters; + } + + public function setParameters($parameters) + { + $this->parameters = $parameters; + } + + public function getLaunchData() + { + return $this->launchData; + } + + public function setLaunchData($launchData) + { + $this->launchData = $launchData; + } + + public function getMaxTimeAllowed() + { + return $this->maxTimeAllowed; + } + + public function setMaxTimeAllowed($maxTimeAllowed) + { + $this->maxTimeAllowed = $maxTimeAllowed; + } + + public function getTimeLimitAction() + { + return $this->timeLimitAction; + } + + public function setTimeLimitAction($timeLimitAction) + { + $this->timeLimitAction = $timeLimitAction; + } + + public function isBlock() + { + return $this->block; + } + + public function setBlock($block) + { + $this->block = $block; + } + + public function getScoreToPass() + { + if (Scorm::SCORM_2004 === $this->scorm->getVersion()) { + return $this->scoreToPassDecimal; + } else { + return $this->scoreToPassInt; + } + } + + public function setScoreToPass($scoreToPass) + { + if (Scorm::SCORM_2004 === $this->scorm->getVersion()) { + $this->setScoreToPassDecimal($scoreToPass); + } else { + $this->setScoreToPassInt($scoreToPass); + } + } + + public function getScoreToPassInt() + { + return $this->scoreToPassInt; + } + + public function setScoreToPassInt($scoreToPassInt) + { + $this->scoreToPassInt = $scoreToPassInt; + } + + public function getScoreToPassDecimal() + { + return $this->scoreToPassDecimal; + } + + public function setScoreToPassDecimal($scoreToPassDecimal) + { + $this->scoreToPassDecimal = $scoreToPassDecimal; + } + + public function getCompletionThreshold() + { + return $this->completionThreshold; + } + + public function setCompletionThreshold($completionThreshold) + { + $this->completionThreshold = $completionThreshold; + } + + public function getPrerequisites() + { + return $this->prerequisites; + } + + public function setPrerequisites($prerequisites) + { + $this->prerequisites = $prerequisites; + } + + /** + * @return array + */ + public function serialize(Sco $sco) + { + $scorm = $sco->getScorm(); + $parent = $sco->getScoParent(); + + return [ + 'id' => $sco->getUuid(), + 'scorm' => !empty($scorm) ? ['id' => $scorm->getUuid()] : null, + 'data' => [ + 'entryUrl' => $sco->getEntryUrl(), + 'identifier' => $sco->getIdentifier(), + 'title' => $sco->getTitle(), + 'visible' => $sco->isVisible(), + 'parameters' => $sco->getParameters(), + 'launchData' => $sco->getLaunchData(), + 'maxTimeAllowed' => $sco->getMaxTimeAllowed(), + 'timeLimitAction' => $sco->getTimeLimitAction(), + 'block' => $sco->isBlock(), + 'scoreToPassInt' => $sco->getScoreToPassInt(), + 'scoreToPassDecimal' => $sco->getScoreToPassDecimal(), + 'scoreToPass' => !empty($scorm) ? $sco->getScoreToPass() : null, + 'completionThreshold' => $sco->getCompletionThreshold(), + 'prerequisites' => $sco->getPrerequisites(), + ], + 'parent' => !empty($parent) ? ['id' => $parent->getUuid()] : null, + 'children' => array_map(function (Sco $scoChild) { + return $this->serialize($scoChild); + }, is_array($sco->getScoChildren()) ? $sco->getScoChildren() : $sco->getScoChildren()->toArray()), + ]; + } +} diff --git a/src/Entity/ScoTracking.php b/src/Entity/ScoTracking.php new file mode 100644 index 0000000..c0b001d --- /dev/null +++ b/src/Entity/ScoTracking.php @@ -0,0 +1,343 @@ +<?php + + +namespace Peopleaps\Scorm\Entity; + + +class ScoTracking +{ + public $userId; + public $sco; + public $scoreRaw; + public $scoreMin; + public $scoreMax; + public $progression = 0; + public $scoreScaled; + public $lessonStatus; + public $completionStatus; + public $sessionTime; + public $totalTimeInt; + public $totalTimeString; + public $entry; + public $suspendData; + public $credit; + public $exitMode; + public $lessonLocation; + public $lessonMode; + public $isLocked; + public $details; + public $latestDate; + + public function getUserId() + { + return $this->userId; + } + + public function setUserId($userId) + { + $this->userId = $userId; + } + + /** + * @return Sco + */ + public function getSco() + { + return $this->sco; + } + + public function setSco($sco) + { + $this->sco = $sco; + } + + public function getScoreRaw() + { + return $this->scoreRaw; + } + + public function setScoreRaw($scoreRaw) + { + $this->scoreRaw = $scoreRaw; + } + + public function getScoreMin() + { + return $this->scoreMin; + } + + public function setScoreMin($scoreMin) + { + $this->scoreMin = $scoreMin; + } + + public function getScoreMax() + { + return $this->scoreMax; + } + + public function setScoreMax($scoreMax) + { + $this->scoreMax = $scoreMax; + } + + public function getScoreScaled() + { + return $this->scoreScaled; + } + + public function setScoreScaled($scoreScaled) + { + $this->scoreScaled = $scoreScaled; + } + + public function getLessonStatus() + { + return $this->lessonStatus; + } + + public function setLessonStatus($lessonStatus) + { + $this->lessonStatus = $lessonStatus; + } + + public function getCompletionStatus() + { + return $this->completionStatus; + } + + public function setCompletionStatus($completionStatus) + { + $this->completionStatus = $completionStatus; + } + + public function getSessionTime() + { + return $this->sessionTime; + } + + public function setSessionTime($sessionTime) + { + $this->sessionTime = $sessionTime; + } + + public function getTotalTime($scormVersion) + { + if (Scorm::SCORM_2004 === $scormVersion) { + return $this->totalTimeString; + } else { + return $this->totalTimeInt; + } + } + + public function setTotalTime($totalTime, $scormVersion) + { + if (Scorm::SCORM_2004 === $scormVersion) { + $this->setTotalTimeString($totalTime); + } else { + $this->setTotalTimeInt($totalTime); + } + } + + public function getTotalTimeInt() + { + return $this->totalTimeInt; + } + + public function setTotalTimeInt($totalTimeInt) + { + $this->totalTimeInt = $totalTimeInt; + } + + public function getTotalTimeString() + { + return $this->totalTimeString; + } + + public function setTotalTimeString($totalTimeString) + { + $this->totalTimeString = $totalTimeString; + } + + public function getEntry() + { + return $this->entry; + } + + public function setEntry($entry) + { + $this->entry = $entry; + } + + public function getSuspendData() + { + return $this->suspendData; + } + + public function setSuspendData($suspendData) + { + $this->suspendData = $suspendData; + } + + public function getCredit() + { + return $this->credit; + } + + public function setCredit($credit) + { + $this->credit = $credit; + } + + public function getExitMode() + { + return $this->exitMode; + } + + public function setExitMode($exitMode) + { + $this->exitMode = $exitMode; + } + + public function getLessonLocation() + { + return $this->lessonLocation; + } + + public function setLessonLocation($lessonLocation) + { + $this->lessonLocation = $lessonLocation; + } + + public function getLessonMode() + { + return $this->lessonMode; + } + + public function setLessonMode($lessonMode) + { + $this->lessonMode = $lessonMode; + } + + public function getIsLocked() + { + return $this->isLocked; + } + + public function setIsLocked($isLocked) + { + $this->isLocked = $isLocked; + } + + public function getDetails() + { + return $this->details; + } + + public function setDetails($details) + { + $this->details = $details; + } + + public function getLatestDate() + { + return $this->latestDate; + } + + public function setLatestDate(\DateTime $latestDate = null) + { + $this->latestDate = $latestDate; + } + + public function getProgression() + { + return $this->progression; + } + + public function setProgression($progression) + { + $this->progression = $progression; + } + + public function getFormattedTotalTime() + { + if (Scorm::SCORM_2004 === $this->sco->getScorm()->getVersion()) { + return $this->getFormattedTotalTimeString(); + } else { + return $this->getFormattedTotalTimeInt(); + } + } + + public function getFormattedTotalTimeInt() + { + $remainingTime = $this->totalTimeInt; + $hours = intval($remainingTime / 360000); + $remainingTime %= 360000; + $minutes = intval($remainingTime / 6000); + $remainingTime %= 6000; + $seconds = intval($remainingTime / 100); + $remainingTime %= 100; + + $formattedTime = ''; + + if ($hours < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $hours.':'; + + if ($minutes < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $minutes.':'; + + if ($seconds < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $seconds.'.'; + + if ($remainingTime < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $remainingTime; + + return $formattedTime; + } + + public function getFormattedTotalTimeString() + { + $pattern = '/^P([0-9]+Y)?([0-9]+M)?([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+S)?$/'; + $formattedTime = ''; + + if (!empty($this->totalTimeString) && 'PT' !== $this->totalTimeString && preg_match($pattern, $this->totalTimeString)) { + $interval = new \DateInterval($this->totalTimeString); + $time = new \DateTime(); + $time->setTimestamp(0); + $time->add($interval); + $timeInSecond = $time->getTimestamp(); + + $hours = intval($timeInSecond / 3600); + $timeInSecond %= 3600; + $minutes = intval($timeInSecond / 60); + $timeInSecond %= 60; + + if ($hours < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $hours.':'; + + if ($minutes < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $minutes.':'; + + if ($timeInSecond < 10) { + $formattedTime .= '0'; + } + $formattedTime .= $timeInSecond; + } else { + $formattedTime .= '00:00:00'; + } + + return $formattedTime; + } +} diff --git a/src/Entity/Scorm.php b/src/Entity/Scorm.php new file mode 100644 index 0000000..4095986 --- /dev/null +++ b/src/Entity/Scorm.php @@ -0,0 +1,146 @@ +<?php + + +namespace Peopleaps\Scorm\Entity; + + +use Doctrine\Common\Collections\ArrayCollection; + +class Scorm +{ + const SCORM_12 = 'scorm_12'; + const SCORM_2004 = 'scorm_2004'; + + public $uuid; + public $id; + public $version; + public $hashName; + public $ratio = 56.25; + public $scos; + public $scoSerializer; + + /** + * @return string + */ + public function getUuid() + { + return $this->uuid; + } + + /** + * @param int $id + */ + public function setUuid($uuid) + { + $this->uuid = $uuid; + } + + /** + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * @param int $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * @param string $version + */ + public function setVersion($version) + { + $this->version = $version; + } + + /** + * @return string + */ + public function getHashName() + { + return $this->hashName; + } + + /** + * @param string $hashName + */ + public function setHashName($hashName) + { + $this->hashName = $hashName; + } + + /** + * @return float + */ + public function getRatio() + { + return $this->ratio; + } + + /** + * @param float $ratio + */ + public function setRatio($ratio) + { + $this->ratio = $ratio; + } + + /** + * @return Sco[] + */ + public function getScos() + { + return $this->scos; + } + + /** + * @return Sco[] + */ + public function getRootScos() + { + $roots = []; + + if (!empty($this->scos)) { + foreach ($this->scos as $sco) { + if (is_null($sco->getScoParent())) { + // Root sco found + $roots[] = $sco; + } + } + } + + return $roots; + } + + public function serialize(Scorm $scorm) + { + return [ + 'id' => $scorm->getUuid(), + 'version' => $scorm->getVersion(), + 'hashName' => $scorm->getHashName(), + 'ratio' => $scorm->getRatio(), + 'scos' => $this->serializeScos($scorm), + ]; + } + + private function serializeScos(Scorm $scorm) + { + return array_map(function (Sco $sco) { + return $sco->serialize($sco); + }, $scorm->getRootScos()); + } +} diff --git a/src/Exception/InvalidScormArchiveException.php b/src/Exception/InvalidScormArchiveException.php new file mode 100644 index 0000000..34a3a6c --- /dev/null +++ b/src/Exception/InvalidScormArchiveException.php @@ -0,0 +1,11 @@ +<?php + + +namespace Peopleaps\Scorm\Exception; + +use Exception; + +class InvalidScormArchiveException extends Exception +{ + +} diff --git a/src/Exception/StorageNotFoundException.php b/src/Exception/StorageNotFoundException.php new file mode 100644 index 0000000..e657271 --- /dev/null +++ b/src/Exception/StorageNotFoundException.php @@ -0,0 +1,11 @@ +<?php + + +namespace Peopleaps\Scorm\Exception; + +use Exception; + +class StorageNotFoundException extends Exception +{ + +} diff --git a/src/Facade/ScormManager.php b/src/Facade/ScormManager.php new file mode 100644 index 0000000..fe8cbfe --- /dev/null +++ b/src/Facade/ScormManager.php @@ -0,0 +1,14 @@ +<?php + + +namespace Peopleaps\Scorm\Facade; + +use Illuminate\Support\Facades\Facade; + +class ScormManager extends Facade +{ + protected static function getFacadeAccessor() + { + return 'scorm-manager'; + } +} diff --git a/src/Library/ScormLib.php b/src/Library/ScormLib.php new file mode 100644 index 0000000..0ca8a6b --- /dev/null +++ b/src/Library/ScormLib.php @@ -0,0 +1,253 @@ +<?php + + +namespace Peopleaps\Scorm\Library; + + +use DOMDocument; +use Peopleaps\Scorm\Entity\Sco; +use Peopleaps\Scorm\Exception\InvalidScormArchiveException; +use Ramsey\Uuid\Uuid; + +class ScormLib +{ + /** + * Looks for the organization to use. + * + * @return array of Sco + * + * @throws InvalidScormArchiveException If a default organization + * is defined and not found + */ + public function parseOrganizationsNode(DOMDocument $dom) + { + $organizationsList = $dom->getElementsByTagName('organizations'); + $resources = $dom->getElementsByTagName('resource'); + + if ($organizationsList->length > 0) { + $organizations = $organizationsList->item(0); + $organization = $organizations->firstChild; + + if (!is_null($organizations->attributes) + && !is_null($organizations->attributes->getNamedItem('default'))) { + $defaultOrganization = $organizations->attributes->getNamedItem('default')->nodeValue; + } else { + $defaultOrganization = null; + } + // No default organization is defined + if (is_null($defaultOrganization)) { + while (!is_null($organization) + && 'organization' !== $organization->nodeName) { + $organization = $organization->nextSibling; + } + + if (is_null($organization)) { + return $this->parseResourceNodes($resources); + } + } + // A default organization is defined + // Look for it + else { + while (!is_null($organization) + && ('organization' !== $organization->nodeName + || is_null($organization->attributes->getNamedItem('identifier')) + || $organization->attributes->getNamedItem('identifier')->nodeValue !== $defaultOrganization)) { + $organization = $organization->nextSibling; + } + + if (is_null($organization)) { + throw new InvalidScormArchiveException('default_organization_not_found_message'); + } + } + + return $this->parseItemNodes($organization, $resources); + } else { + throw new InvalidScormArchiveException('no_organization_found_message'); + } + } + + /** + * Creates defined structure of SCOs. + * + * @return array of Sco + * + * @throws InvalidScormArchiveException + */ + private function parseItemNodes(\DOMNode $source, \DOMNodeList $resources, Sco $parentSco = null) + { + $item = $source->firstChild; + $scos = []; + + while (!is_null($item)) { + if ('item' === $item->nodeName) { + $sco = new Sco(); + $scos[] = $sco; + $sco->setUuid(Uuid::uuid4()); + $sco->setScoParent($parentSco); + $this->findAttrParams($sco, $item, $resources); + $this->findNodeParams($sco, $item->firstChild); + + if ($sco->isBlock()) { + $sco->setScoChildren($this->parseItemNodes($item, $resources, $sco)); + } + } + $item = $item->nextSibling; + } + + return $scos; + } + + private function parseResourceNodes(\DOMNodeList $resources) + { + $scos = []; + + foreach ($resources as $resource) { + if (!is_null($resource->attributes)) { + $scormType = $resource->attributes->getNamedItemNS( + $resource->lookupNamespaceUri('adlcp'), + 'scormType' + ); + + if (!is_null($scormType) && 'sco' === $scormType->nodeValue) { + $identifier = $resource->attributes->getNamedItem('identifier'); + $href = $resource->attributes->getNamedItem('href'); + + if (is_null($identifier)) { + throw new InvalidScormArchiveException('sco_with_no_identifier_message'); + } + if (is_null($href)) { + throw new InvalidScormArchiveException('sco_resource_without_href_message'); + } + $sco = new Sco(); + $sco->setUuid(Uuid::uuid4()); + $sco->setBlock(false); + $sco->setVisible(true); + $sco->setIdentifier($identifier->nodeValue); + $sco->setTitle($identifier->nodeValue); + $sco->setEntryUrl($href->nodeValue); + $scos[] = $sco; + } + } + } + + return $scos; + } + + /** + * Initializes parameters of the SCO defined in attributes of the node. + * It also look for the associated resource if it is a SCO and not a block. + * + * @throws InvalidScormArchiveException + */ + private function findAttrParams(Sco $sco, \DOMNode $item, \DOMNodeList $resources) + { + $identifier = $item->attributes->getNamedItem('identifier'); + $isVisible = $item->attributes->getNamedItem('isvisible'); + $identifierRef = $item->attributes->getNamedItem('identifierref'); + $parameters = $item->attributes->getNamedItem('parameters'); + + // throws an Exception if identifier is undefined + if (is_null($identifier)) { + throw new InvalidScormArchiveException('sco_with_no_identifier_message'); + } + $sco->setIdentifier($identifier->nodeValue); + + // visible is true by default + if (!is_null($isVisible) && 'false' === $isVisible) { + $sco->setVisible(false); + } else { + $sco->setVisible(true); + } + + // set parameters for SCO entry resource + if (!is_null($parameters)) { + $sco->setParameters($parameters->nodeValue); + } + + // check if item is a block or a SCO. A block doesn't define identifierref + if (is_null($identifierRef)) { + $sco->setBlock(true); + } else { + $sco->setBlock(false); + // retrieve entry URL + $sco->setEntryUrl($this->findEntryUrl($identifierRef->nodeValue, $resources)); + } + } + + /** + * Initializes parameters of the SCO defined in children nodes. + */ + private function findNodeParams(Sco $sco, \DOMNode $item) + { + while (!is_null($item)) { + switch ($item->nodeName) { + case 'title': + $sco->setTitle($item->nodeValue); + break; + case 'adlcp:masteryscore': + $sco->setScoreToPassInt($item->nodeValue); + break; + case 'adlcp:maxtimeallowed': + case 'imsss:attemptAbsoluteDurationLimit': + $sco->setMaxTimeAllowed($item->nodeValue); + break; + case 'adlcp:timelimitaction': + case 'adlcp:timeLimitAction': + $action = strtolower($item->nodeValue); + + if ('exit,message' === $action + || 'exit,no message' === $action + || 'continue,message' === $action + || 'continue,no message' === $action) { + $sco->setTimeLimitAction($action); + } + break; + case 'adlcp:datafromlms': + case 'adlcp:dataFromLMS': + $sco->setLaunchData($item->nodeValue); + break; + case 'adlcp:prerequisites': + $sco->setPrerequisites($item->nodeValue); + break; + case 'imsss:minNormalizedMeasure': + $sco->setScoreToPassDecimal($item->nodeValue); + break; + case 'adlcp:completionThreshold': + if ($item->nodeValue && !is_nan($item->nodeValue)) { + $sco->setCompletionThreshold(floatval($item->nodeValue)); + } + break; + } + $item = $item->nextSibling; + } + } + + /** + * Searches for the resource with the given id and retrieve URL to its content. + * + * @return string URL to the resource associated to the SCO + * + * @throws InvalidScormArchiveException + */ + public function findEntryUrl($identifierref, \DOMNodeList $resources) + { + foreach ($resources as $resource) { + $identifier = $resource->attributes->getNamedItem('identifier'); + + if (!is_null($identifier)) { + $identifierValue = $identifier->nodeValue; + + if ($identifierValue === $identifierref) { + $href = $resource->attributes->getNamedItem('href'); + + if (is_null($href)) { + throw new InvalidScormArchiveException('sco_resource_without_href_message'); + } + + return $href->nodeValue; + } + } + } + throw new InvalidScormArchiveException('sco_without_resource_message'); + } +} 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; + } +} diff --git a/src/Model/ScormModel.php b/src/Model/ScormModel.php new file mode 100644 index 0000000..d5c8499 --- /dev/null +++ b/src/Model/ScormModel.php @@ -0,0 +1,19 @@ +<?php + + +namespace Peopleaps\Scorm\Model; + + +use Illuminate\Database\Eloquent\Model; + +class ScormModel extends Model +{ + public function getTable() + { + return config('scorm.table_names.scorm_table', parent::getTable()); + } + + public function scos() { + return $this->hasMany(ScormScoModel::class, 'scorm_id', 'id'); + } +} diff --git a/src/Model/ScormScoModel.php b/src/Model/ScormScoModel.php new file mode 100644 index 0000000..6577514 --- /dev/null +++ b/src/Model/ScormScoModel.php @@ -0,0 +1,19 @@ +<?php + + +namespace Peopleaps\Scorm\Model; + + +use Illuminate\Database\Eloquent\Model; + +class ScormScoModel extends Model +{ + public function getTable() + { + return config('scorm.table_names.scorm_sco_table', parent::getTable()); + } + + public function scorm() { + return $this->belongsTo(ScormModel::class, 'scorm_id', 'id'); + } +} diff --git a/src/Model/ScormScoTrackingModel.php b/src/Model/ScormScoTrackingModel.php new file mode 100644 index 0000000..1a26efb --- /dev/null +++ b/src/Model/ScormScoTrackingModel.php @@ -0,0 +1,42 @@ +<?php + + +namespace Peopleaps\Scorm\Model; + + +use Illuminate\Database\Eloquent\Model; + +class ScormScoTrackingModel extends Model +{ + protected $fillable = [ + 'user_id', + 'sco_id', + 'uuid', + 'progression', + 'score_raw', + 'score_min', + 'score_max', + 'score_scaled', + 'lesson_status', + 'completion_status', + 'session_time', + 'total_time_int', + 'total_time_string', + 'entry', + 'suspend_data', + 'credit', + 'exit_mode', + 'lesson_location', + 'lesson_mode', + 'is_locked', + 'details', + 'latest_date', + 'created_at', + 'updated_at' + ]; + + public function getTable() + { + return config('scorm.table_names.scorm_sco_tracking_table', parent::getTable()); + } +} diff --git a/src/ScormServiceProvider.php b/src/ScormServiceProvider.php new file mode 100644 index 0000000..1806775 --- /dev/null +++ b/src/ScormServiceProvider.php @@ -0,0 +1,62 @@ +<?php + + +namespace Peopleaps\Scorm; + + +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; +use Illuminate\Support\ServiceProvider; +use Peopleaps\Scorm\Manager\ScormManager; + +class ScormServiceProvider extends ServiceProvider +{ + public function register() + { + $this->app->bind('scorm-manager', function($app) { + return new ScormManager(); + }); + } + + public function boot() + { + $this->offerPublishing(); + } + + protected function offerPublishing() { + // function not available and 'publish' not relevant in Lumen + if (! function_exists('config_path')) { + return; + } + + $this->publishes([ + __DIR__.'/../config/scorm.php' => config_path('scorm.php'), + ], 'config'); + + $this->publishes([ + __DIR__.'/../database/migrations/create_scorm_tables.php.stub' => $this->getMigrationFileName('create_scorm_tables.php'), + ], 'migrations'); + } + + /** + * Returns existing migration file if found, else uses the current timestamp. + * + * @return string + */ + protected function getMigrationFileName($migrationFileName): string + { + $timestamp = date('Y_m_d_His'); + + $filesystem = $this->app->make(Filesystem::class); + + return Collection::make($this->app->databasePath().DIRECTORY_SEPARATOR.'migrations'.DIRECTORY_SEPARATOR) + ->flatMap(function ($path) use ($filesystem) { + return $filesystem->glob($path.'*_create_scorm_tables.php'); + })->push($this->app->databasePath()."/migrations/{$timestamp}_create_scorm_tables.php") + ->flatMap(function ($path) use ($filesystem, $migrationFileName) { + return $filesystem->glob($path.'*_'.$migrationFileName); + }) + ->push($this->app->databasePath()."/migrations/{$timestamp}_{$migrationFileName}") + ->first(); + } +} |