diff --git a/README.md b/README.md index 4aaec66..b0dd5a2 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,42 @@ php artisan vendor:publish --provider="Peopleaps\Scorm\ScormServiceProvider" ``` ## Step 3: +Run config cache for update cached configuration +```sh +php artisan config:cache +``` + +## Step 4: Migrate file to database ```sh php artisan migrate ``` +## Step 5 (Optional): +update SCORM config under config/scorm +- update scorm table names. +- update SCORM disk and configure disk @see config/filesystems.php +``` + 'disk' => 'scorm-local', + 'disk' => 'scorm-s3', + + // @see config/filesystems.php + 'disks' => [ + ..... + 'scorm-local' => [ + 'driver' => 'local', + 'root' => env('SCORM_ROOT_DIR'), // set root dir + 'visibility' => 'public', + ], + + 's3-scorm' => [ + 'driver' => 's3', + 'root' => env('SCORM_ROOT_DIR'), // set root dir + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_SCORM_BUCKET'), + ], + ..... + ] +``` diff --git a/config/scorm.php b/config/scorm.php index e5efb3f..0330bc5 100644 --- a/config/scorm.php +++ b/config/scorm.php @@ -3,11 +3,25 @@ return [ 'table_names' => [ - 'user_table' => 'users', + 'user_table' => 'users', // user table name on main LMS app. 'scorm_table' => 'scorm', 'scorm_sco_table' => 'scorm_sco', 'scorm_sco_tracking_table' => 'scorm_sco_tracking', ], - // Scorm directory. You may create a custom path in file system + /** + * Scorm directory. You may create a custom path in file system + * Define Scorm disk under @see config/filesystems.php + * 'disk' => 'local', + * 'disk' => 's3-scorm', + * ex. + * 's3-scorm' => [ + * 'driver' => 's3', + * 'root' => env('SCORM_ROOT_DIR'), // define root dir + * 'key' => env('AWS_ACCESS_KEY_ID'), + * 'secret' => env('AWS_SECRET_ACCESS_KEY'), + * 'region' => env('AWS_DEFAULT_REGION'), + * 'bucket' => env('AWS_SCORM_BUCKET'), + * ], + */ 'disk' => 'local', ]; diff --git a/database/migrations/create_scorm_tables.php.stub b/database/migrations/create_scorm_tables.php.stub index 506e9cb..6a136b3 100644 --- a/database/migrations/create_scorm_tables.php.stub +++ b/database/migrations/create_scorm_tables.php.stub @@ -97,19 +97,7 @@ class CreateScormTables extends Migration */ public function down() { - $tableNames = config('scorm_sco_tracking_table'); - - if (empty($tableNames)) { - throw new \Exception('Error: Table not found.'); - } - - $tableNames = config('scorm_sco_table'); - - if (empty($tableNames)) { - throw new \Exception('Error: Table not found.'); - } - - $tableNames = config('scorm_table'); + $tableNames = config('scorm.table_names'); if (empty($tableNames)) { throw new \Exception('Error: Table not found.'); diff --git a/src/Entity/Scorm.php b/src/Entity/Scorm.php index 4095986..48fd711 100644 --- a/src/Entity/Scorm.php +++ b/src/Entity/Scorm.php @@ -3,9 +3,6 @@ namespace Peopleaps\Scorm\Entity; - -use Doctrine\Common\Collections\ArrayCollection; - class Scorm { const SCORM_12 = 'scorm_12'; @@ -13,8 +10,9 @@ class Scorm public $uuid; public $id; + public $title; public $version; - public $hashName; + public $entryUrl; public $ratio = 56.25; public $scos; public $scoSerializer; @@ -70,17 +68,33 @@ public function setVersion($version) /** * @return string */ - public function getHashName() + public function getTitle() + { + return $this->title; + } + + /** + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * @return string + */ + public function getEntryUrl() { - return $this->hashName; + return $this->entryUrl; } /** - * @param string $hashName + * @param string $title */ - public function setHashName($hashName) + public function setEntryUrl($entryUrl) { - $this->hashName = $hashName; + $this->entryUrl = $entryUrl; } /** @@ -131,7 +145,8 @@ public function serialize(Scorm $scorm) return [ 'id' => $scorm->getUuid(), 'version' => $scorm->getVersion(), - 'hashName' => $scorm->getHashName(), + 'title' => $scorm->getTitle(), + 'entryUrl' => $scorm->getEntryUrl(), 'ratio' => $scorm->getRatio(), 'scos' => $this->serializeScos($scorm), ]; diff --git a/src/Library/ScormLib.php b/src/Library/ScormLib.php index 0ca8a6b..731601a 100644 --- a/src/Library/ScormLib.php +++ b/src/Library/ScormLib.php @@ -7,7 +7,7 @@ use DOMDocument; use Peopleaps\Scorm\Entity\Sco; use Peopleaps\Scorm\Exception\InvalidScormArchiveException; -use Ramsey\Uuid\Uuid; +use Illuminate\Support\Str; class ScormLib { @@ -28,16 +28,20 @@ public function parseOrganizationsNode(DOMDocument $dom) $organizations = $organizationsList->item(0); $organization = $organizations->firstChild; - if (!is_null($organizations->attributes) - && !is_null($organizations->attributes->getNamedItem('default'))) { + 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) { + while ( + !is_null($organization) + && 'organization' !== $organization->nodeName + ) { $organization = $organization->nextSibling; } @@ -48,10 +52,12 @@ public function parseOrganizationsNode(DOMDocument $dom) // A default organization is defined // Look for it else { - while (!is_null($organization) + while ( + !is_null($organization) && ('organization' !== $organization->nodeName || is_null($organization->attributes->getNamedItem('identifier')) - || $organization->attributes->getNamedItem('identifier')->nodeValue !== $defaultOrganization)) { + || $organization->attributes->getNamedItem('identifier')->nodeValue !== $defaultOrganization) + ) { $organization = $organization->nextSibling; } @@ -82,7 +88,7 @@ private function parseItemNodes(\DOMNode $source, \DOMNodeList $resources, Sco $ if ('item' === $item->nodeName) { $sco = new Sco(); $scos[] = $sco; - $sco->setUuid(Uuid::uuid4()); + $sco->setUuid(Str::uuid()); $sco->setScoParent($parentSco); $this->findAttrParams($sco, $item, $resources); $this->findNodeParams($sco, $item->firstChild); @@ -119,7 +125,7 @@ private function parseResourceNodes(\DOMNodeList $resources) throw new InvalidScormArchiveException('sco_resource_without_href_message'); } $sco = new Sco(); - $sco->setUuid(Uuid::uuid4()); + $sco->setUuid(Str::uuid()); $sco->setBlock(false); $sco->setVisible(true); $sco->setIdentifier($identifier->nodeValue); @@ -195,10 +201,12 @@ private function findNodeParams(Sco $sco, \DOMNode $item) case 'adlcp:timeLimitAction': $action = strtolower($item->nodeValue); - if ('exit,message' === $action + if ( + 'exit,message' === $action || 'exit,no message' === $action || 'continue,message' === $action - || 'continue,no message' === $action) { + || 'continue,no message' === $action + ) { $sco->setTimeLimitAction($action); } break; diff --git a/src/Manager/ScormDisk.php b/src/Manager/ScormDisk.php new file mode 100644 index 0000000..249f027 --- /dev/null +++ b/src/Manager/ScormDisk.php @@ -0,0 +1,74 @@ +cleanPath($path); + + $zipArchive = new ZipArchive(); + if ($zipArchive->open($file) !== true) { + return false; + } + + /** @var FilesystemAdapter $disk */ + $disk = $this->getDisk(); + + for ($i = 0; $i < $zipArchive->numFiles; ++$i) { + $zipEntryName = $zipArchive->getNameIndex($i); + $destination = $path . DIRECTORY_SEPARATOR . $this->cleanPath($zipEntryName); + if ($this->isDirectory($zipEntryName)) { + $disk->createDir($destination); + continue; + } + $disk->putStream($destination, $zipArchive->getStream($zipEntryName)); + } + + return true; + } + + /** + * @param string $directory + * @return bool + */ + public function deleteScormFolder($folderHashedName) + { + return $this->getDisk()->deleteDirectory($folderHashedName); + } + + private function isDirectory($zipEntryName) + { + return substr($zipEntryName, -1) === '/'; + } + + private function cleanPath($path) + { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + /** + * @return FilesystemAdapter $disk + */ + private function getDisk() + { + if (!config()->has('filesystems.disks.' . config('scorm.disk'))) { + throw new StorageNotFoundException('scorm_disk_not_define'); + } + return Storage::disk(config('scorm.disk')); + } +} diff --git a/src/Manager/ScormManager.php b/src/Manager/ScormManager.php index 2a54706..56c723c 100644 --- a/src/Manager/ScormManager.php +++ b/src/Manager/ScormManager.php @@ -3,31 +3,28 @@ namespace Peopleaps\Scorm\Manager; -use App\Models\User; use Carbon\Carbon; use DOMDocument; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; 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 Illuminate\Support\Str; +use Peopleaps\Scorm\Entity\Sco; use ZipArchive; class ScormManager { /** @var ScormLib */ private $scormLib; + /** @var ScormDisk */ + private $scormDisk; /** * Constructor. @@ -35,18 +32,18 @@ class ScormManager * @param string $filesDir * @param string $uploadDir */ - public function __construct( - ) { + public function __construct() + { $this->scormLib = new ScormLib(); + $this->scormDisk = new ScormDisk(); } - public function uploadScormArchive(UploadedFile $file, Model $model) + public function uploadScormArchive(UploadedFile $file) { // 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'); @@ -58,57 +55,71 @@ public function uploadScormArchive(UploadedFile $file, Model $model) $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 (is_null($scormData) || !is_array($scormData)) { + throw new InvalidScormArchiveException('invalid_scorm_data'); + } - if (!empty($scormData['scos']) && is_array($scormData['scos'])) { - foreach ($scormData['scos'] as $scoData) { + $scorm = ScormModel::whereOriginFile($scormData['identifier']); + // Check if scom package already exists to drop old one. + if (!$scorm->exists()) { + $scorm = new ScormModel(); + } else { + $scorm = $scorm->first(); + $this->deleteScormData($scorm); + } - $scoParent = null; - if (!empty($scoData->scoParent)) { - $scoParent = ScormScoModel::where('uuid', $scoData->scoParent->uuid)->first(); + $scorm->uuid = $scormData['uuid']; + $scorm->title = $scormData['title']; + $scorm->version = $scormData['version']; + $scorm->entry_url = $scormData['entryUrl']; + $scorm->origin_file = $scormData['identifier']; + $scorm->save(); + + if (!empty($scormData['scos']) && is_array($scormData['scos'])) { + /** @var Sco $scoData */ + foreach ($scormData['scos'] as $scoData) { + $sco = $this->saveScormScos($scorm->id, $scoData); + if ($scoData->scoChildren) { + foreach ($scoData->scoChildren as $scoChild) { + $this->saveScormScos($scorm->id, $scoChild, $sco->id); } - - $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; } + /** + * Save Scorm sco and it's nested children + * @param int $scorm_id scorm id. + * @param Sco $scoData Sco data to be store. + * @param int $sco_parent_id sco parent id for children + */ + private function saveScormScos($scorm_id, $scoData, $sco_parent_id = null) + { + $sco = new ScormScoModel(); + $sco->scorm_id = $scorm_id; + $sco->uuid = $scoData->uuid; + $sco->sco_parent_id = $sco_parent_id; + $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 $sco; + } + private function parseScormArchive(UploadedFile $file) { $data = []; @@ -129,8 +140,18 @@ private function parseScormArchive(UploadedFile $file) throw new InvalidScormArchiveException('cannot_load_imsmanifest_message'); } - $scormVersionElements = $dom->getElementsByTagName('schemaversion'); + $manifest = $dom->getElementsByTagName('manifest')->item(0); + if (!is_null($manifest->attributes->getNamedItem('identifier'))) { + $data['identifier'] = $manifest->attributes->getNamedItem('identifier')->nodeValue; + } else { + throw new InvalidScormArchiveException('invalid_scorm_manifest_identifier'); + } + $titles = $dom->getElementsByTagName('title'); + if ($titles->length > 0) { + $data['title'] = Str::of($titles->item(0)->textContent)->trim('/n')->trim(); + } + $scormVersionElements = $dom->getElementsByTagName('schemaversion'); if ($scormVersionElements->length > 0) { switch ($scormVersionElements->item(0)->textContent) { case '1.2': @@ -152,69 +173,43 @@ private function parseScormArchive(UploadedFile $file) if (0 >= count($scos)) { throw new InvalidScormArchiveException('no_sco_in_scorm_archive_message'); } + + $data['entryUrl'] = $scos[0]->entryUrl ?? $scos[0]->scoChildren[0]->entryUrl; $data['scos'] = $scos; return $data; } - public function deleteScormData($model) { + public function deleteScorm($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 + $this->deleteScormData($model); $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); + private function deleteScormData($model) + { + // Delete after the previous item is stored + $oldScos = $model->scos()->get(); - return $response; + // Delete all tracking associate with sco + foreach ($oldScos as $oldSco) { + $oldSco->scoTrackings()->delete(); + } + $model->scos()->delete(); // delete scos + // Delete folder from server + $this->deleteScormFolder($model->uuid); } /** - * Unzip a given ZIP file into the web resources directory. - * - * @param string $hashName name of the destination directory + * @param $folderHashedName + * @return bool */ - private function unzipScormArchive(UploadedFile $file, $hashName) + protected function deleteScormFolder($folderHashedName) { - $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(); + return $this->scormDisk->deleteScormFolder($folderHashedName); } /** @@ -224,32 +219,21 @@ private function unzipScormArchive(UploadedFile $file, $hashName) */ private function generateScorm(UploadedFile $file) { - $hashName = Uuid::uuid4(); - $hashFileName = $hashName.'.zip'; + $uuid = Str::uuid(); $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'); + /** + * Unzip a given ZIP file into the web resources directory. + * + * @param string $hashName name of the destination directory + */ + $this->scormDisk->unzip($file, $uuid); return [ - 'name' => $hashFileName, // to follow standard file data format - 'hashName' => $hashName, - 'type' => $finalFile->getMimeType(), + 'identifier' => $scormData['identifier'], + 'uuid' => $uuid, + 'title' => $scormData['title'], // to follow standard file data format 'version' => $scormData['version'], + 'entryUrl' => $scormData['entryUrl'], 'scos' => $scormData['scos'], ]; } @@ -259,7 +243,8 @@ private function generateScorm(UploadedFile $file) * @param $scormId * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection */ - public function getScos($scormId) { + public function getScos($scormId) + { $scos = ScormScoModel::with([ 'scorm' ])->where('scorm_id', $scormId) @@ -273,7 +258,8 @@ public function getScos($scormId) { * @param $scoUuid * @return null|\Illuminate\Database\Eloquent\Builder|Model */ - public function getScoByUuid($scoUuid) { + public function getScoByUuid($scoUuid) + { $sco = ScormScoModel::with([ 'scorm' ])->where('uuid', $scoUuid) @@ -282,7 +268,8 @@ public function getScoByUuid($scoUuid) { return $sco; } - public function getUserResult($scoId, $userId) { + public function getUserResult($scoId, $userId) + { return ScormScoTrackingModel::where('sco_id', $scoId)->where('user_id', $userId)->first(); } @@ -327,7 +314,7 @@ public function createScoTracking($scoUuid, $userId = null) 'user_id' => $userId, 'sco_id' => $sco->id ], [ - 'uuid' => Uuid::uuid4(), + 'uuid' => Str::uuid(), 'progression' => $scoTracking->getProgression(), 'score_raw' => $scoTracking->getScoreRaw(), 'score_min' => $scoTracking->getScoreMin(), @@ -375,7 +362,8 @@ public function createScoTracking($scoUuid, $userId = null) return $scoTracking; } - public function findScoTrackingId($scoUuid, $scoTrackingUuid) { + public function findScoTrackingId($scoUuid, $scoTrackingUuid) + { return ScormScoTrackingModel::with([ 'sco' ])->whereHas('sco', function (Builder $query) use ($scoUuid) { @@ -384,7 +372,8 @@ public function findScoTrackingId($scoUuid, $scoTrackingUuid) { ->firstOrFail(); } - public function checkUserIsCompletedScorm($scormId, $userId) { + public function checkUserIsCompletedScorm($scormId, $userId) + { $completedSco = []; $scos = ScormScoModel::where('scorm_id', $scormId)->get(); @@ -538,7 +527,8 @@ public function updateScoTracking($scoUuid, $userId, $data) $bestStatus = $lessonStatus; } - if (empty($tracking->getCompletionStatus()) + if ( + empty($tracking->getCompletionStatus()) || ($completionStatus !== $tracking->getCompletionStatus() && $statusPriority[$completionStatus] > $statusPriority[$tracking->getCompletionStatus()]) ) { // This is no longer needed as completionStatus and successStatus are merged together @@ -630,7 +620,7 @@ private function retrieveIntervalFromSeconds($seconds) $remainingTime %= 3600; $nbMinutes = (int) ($remainingTime / 60); $nbSeconds = $remainingTime % 60; - $result .= 'P'.$nbDays.'DT'.$nbHours.'H'.$nbMinutes.'M'.$nbSeconds.'S'; + $result .= 'P' . $nbDays . 'DT' . $nbHours . 'H' . $nbMinutes . 'M' . $nbSeconds . 'S'; } return $result; diff --git a/src/Model/ScormScoModel.php b/src/Model/ScormScoModel.php index de50741..d606d32 100644 --- a/src/Model/ScormScoModel.php +++ b/src/Model/ScormScoModel.php @@ -13,11 +13,18 @@ public function getTable() return config('scorm.table_names.scorm_sco_table', parent::getTable()); } - public function scorm() { + public function scorm() + { return $this->belongsTo(ScormModel::class, 'scorm_id', 'id'); } - public function scoTrackings() { + public function scoTrackings() + { return $this->hasMany(ScormScoTrackingModel::class, 'sco_id', 'id'); } + + public function children() + { + return $this->hasMany(ScormScoModel::class, 'sco_parent_id', 'id'); + } }