├── .gitignore ├── .idea ├── vcs.xml ├── .gitignore ├── modules.xml ├── php.xml └── laravel-scorm.iml ├── src ├── Exception │ ├── StorageNotFoundException.php │ └── InvalidScormArchiveException.php ├── Facade │ └── ScormManager.php ├── Model │ ├── ScormScoModel.php │ ├── ScormScoTrackingModel.php │ └── ScormModel.php ├── ScormServiceProvider.php ├── Entity │ ├── Scorm.php │ ├── Sco.php │ └── ScoTracking.php ├── Manager │ ├── ScormDisk.php │ └── ScormManager.php └── Library │ └── ScormLib.php ├── resources └── lang │ └── en-US │ └── scorm.php ├── composer.json ├── config └── scorm.php ├── LICENSE ├── README.md └── database └── migrations └── create_scorm_tables.php.stub /.gitignore: -------------------------------------------------------------------------------- 1 | /.buildpath 2 | /.project 3 | /.settings 4 | /vendor 5 | composer.lock 6 | composer.phar 7 | Thumbs.db 8 | phpunit.xml 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Exception/StorageNotFoundException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Model/ScormScoModel.php: -------------------------------------------------------------------------------- 1 | belongsTo(ScormModel::class, 'scorm_id', 'id'); 19 | } 20 | 21 | public function scoTrackings() 22 | { 23 | return $this->hasMany(ScormScoTrackingModel::class, 'sco_id', 'id'); 24 | } 25 | 26 | public function children() 27 | { 28 | return $this->hasMany(ScormScoModel::class, 'sco_parent_id', 'id'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/en-US/scorm.php: -------------------------------------------------------------------------------- 1 | 'Invalid SCORM archive.', 5 | 'invalid_scorm_version_message' => 'Invalid SCORM version.', 6 | 'no_sco_in_scorm_archive_message' => 'No items in SCORM archive.', 7 | 'invalid_scorm_data' => 'Invalid SCORM data.', 8 | 'cannot_load_imsmanifest_message' => 'Can not load SCORM manifest.', 9 | 'invalid_scorm_manifest_identifier' => 'Invalid SCORM manifest identifier.', 10 | 'scorm_disk_not_define' => 'SCORM disk not define', 11 | 12 | // SCORM Items/Children messages 13 | 'default_organization_not_found_message' => 'SCORM item default organization not found.', 14 | 'no_organization_found_message' => 'No organization found.', 15 | 'sco_with_no_identifier_message' => 'SCORM item without identifier.', 16 | 'sco_resource_without_href_message' => 'SCORM item resource without entry link.', 17 | 'sco_without_resource_message' => 'SCORM item without resource.' 18 | ]; 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devianl2/laravel-scorm", 3 | "description": "PEOPLEAPS scorm package", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Devian Leong", 9 | "email": "devian@peoplelogy.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.4 || ^8.0", 14 | "doctrine/common": "^3.1", 15 | "league/flysystem": "^2.0 || ^3.0", 16 | "nesbot/carbon": "^2.42", 17 | "ext-zip": "*", 18 | "ext-dom": "*" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Peopleaps\\Scorm\\": "/src" 23 | } 24 | }, 25 | "extra": { 26 | "laravel": { 27 | "providers": [ 28 | "Peopleaps\\Scorm\\ScormServiceProvider" 29 | ], 30 | "aliases": { 31 | "ScormManager": "Peopleaps\\Scorm\\Facade\\ScormManager" 32 | } 33 | } 34 | }, 35 | "minimum-stability": "dev", 36 | "prefer-stable": true 37 | } 38 | -------------------------------------------------------------------------------- /config/scorm.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'user_table' => 'users', // user table name on main LMS app. 7 | 'resource_table' => 'resource', // resource table on LMS app. 8 | 'scorm_table' => 'scorm', 9 | 'scorm_sco_table' => 'scorm_sco', 10 | 'scorm_sco_tracking_table' => 'scorm_sco_tracking', 11 | ], 12 | /** 13 | * Scorm directory. You may create a custom path in file system 14 | * Define Scorm disk under @see config/filesystems.php 15 | * 'disk' => 'local', 16 | * 'disk' => 's3-scorm', 17 | * ex. 18 | * 's3-scorm' => [ 19 | * 'driver' => 's3', 20 | * 'root' => env('SCORM_ROOT_DIR'), // define root dir 21 | * 'key' => env('AWS_ACCESS_KEY_ID'), 22 | * 'secret' => env('AWS_SECRET_ACCESS_KEY'), 23 | * 'region' => env('AWS_DEFAULT_REGION'), 24 | * 'bucket' => env('AWS_SCORM_BUCKET'), 25 | * ], 26 | */ 27 | 'disk' => 'local', 28 | 'archive' => 'local', 29 | ]; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 devianl2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Model/ScormScoTrackingModel.php: -------------------------------------------------------------------------------- 1 | 'array', 40 | ]; 41 | 42 | public function getTable() 43 | { 44 | return config('scorm.table_names.scorm_sco_tracking_table', parent::getTable()); 45 | } 46 | 47 | public function sco() 48 | { 49 | return $this->belongsTo(ScormScoModel::class, 'sco_id', 'id'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Model/ScormModel.php: -------------------------------------------------------------------------------- 1 | morphTo(__FUNCTION__, 'resource_type', 'resource_id'); 45 | } 46 | 47 | public function getTable() 48 | { 49 | return config('scorm.table_names.scorm_table', parent::getTable()); 50 | } 51 | 52 | public function scos() 53 | { 54 | return $this->hasMany(ScormScoModel::class, 'scorm_id', 'id'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.idea/laravel-scorm.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/ScormServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind('scorm-manager', function ($app) { 17 | return new ScormManager(); 18 | }); 19 | } 20 | 21 | public function boot() 22 | { 23 | $this->offerPublishing(); 24 | } 25 | 26 | protected function offerPublishing() 27 | { 28 | // function not available and 'publish' not relevant in Lumen 29 | if (!function_exists('config_path')) { 30 | return; 31 | } 32 | 33 | $this->publishes([ 34 | __DIR__ . '/../config/scorm.php' => config_path('scorm.php'), 35 | ], 'config'); 36 | 37 | $this->publishes([ 38 | __DIR__ . '/../database/migrations/create_scorm_tables.php.stub' => $this->getMigrationFileName('create_scorm_tables.php'), 39 | ], 'migrations'); 40 | 41 | $this->publishes([ 42 | __DIR__ . '/../resources/lang/en-US/scorm.php' => resource_path('lang/en-US/scorm.php'), 43 | ]); 44 | } 45 | 46 | /** 47 | * Returns existing migration file if found, else uses the current timestamp. 48 | * 49 | * @return string 50 | */ 51 | protected function getMigrationFileName($migrationFileName): string 52 | { 53 | $timestamp = date('Y_m_d_His'); 54 | 55 | $filesystem = $this->app->make(Filesystem::class); 56 | 57 | return Collection::make($this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR) 58 | ->flatMap(function ($path) use ($filesystem) { 59 | return $filesystem->glob($path . '*_create_scorm_tables.php'); 60 | })->push($this->app->databasePath() . "/migrations/{$timestamp}_create_scorm_tables.php") 61 | ->flatMap(function ($path) use ($filesystem, $migrationFileName) { 62 | return $filesystem->glob($path . '*_' . $migrationFileName); 63 | }) 64 | ->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}") 65 | ->first(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Entity/Scorm.php: -------------------------------------------------------------------------------- 1 | setId($model->id); 26 | $instance->setUuid($model->uuid); 27 | $instance->setTitle($model->title); 28 | $instance->setVersion($model->version); 29 | $instance->setEntryUrl($model->entryUrl); 30 | return $instance; 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getUuid() 37 | { 38 | return $this->uuid; 39 | } 40 | 41 | /** 42 | * @param int $id 43 | */ 44 | public function setUuid($uuid) 45 | { 46 | $this->uuid = $uuid; 47 | } 48 | 49 | /** 50 | * @return int 51 | */ 52 | public function getId() 53 | { 54 | return $this->id; 55 | } 56 | 57 | /** 58 | * @param int $id 59 | */ 60 | public function setId($id) 61 | { 62 | $this->id = $id; 63 | } 64 | 65 | /** 66 | * @return string 67 | */ 68 | public function getVersion() 69 | { 70 | return $this->version; 71 | } 72 | 73 | /** 74 | * @param string $version 75 | */ 76 | public function setVersion($version) 77 | { 78 | $this->version = $version; 79 | } 80 | 81 | /** 82 | * @return string 83 | */ 84 | public function getTitle() 85 | { 86 | return $this->title; 87 | } 88 | 89 | /** 90 | * @param string $title 91 | */ 92 | public function setTitle($title) 93 | { 94 | $this->title = $title; 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getEntryUrl() 101 | { 102 | return $this->entryUrl; 103 | } 104 | 105 | /** 106 | * @param string $title 107 | */ 108 | public function setEntryUrl($entryUrl) 109 | { 110 | $this->entryUrl = $entryUrl; 111 | } 112 | 113 | /** 114 | * @return float 115 | */ 116 | public function getRatio() 117 | { 118 | return $this->ratio; 119 | } 120 | 121 | /** 122 | * @param float $ratio 123 | */ 124 | public function setRatio($ratio) 125 | { 126 | $this->ratio = $ratio; 127 | } 128 | 129 | /** 130 | * @return Sco[] 131 | */ 132 | public function getScos() 133 | { 134 | return $this->scos; 135 | } 136 | 137 | /** 138 | * @return Sco[] 139 | */ 140 | public function getRootScos() 141 | { 142 | $roots = []; 143 | 144 | if (!empty($this->scos)) { 145 | foreach ($this->scos as $sco) { 146 | if (is_null($sco->getScoParent())) { 147 | // Root sco found 148 | $roots[] = $sco; 149 | } 150 | } 151 | } 152 | 153 | return $roots; 154 | } 155 | 156 | public function serialize(Scorm $scorm) 157 | { 158 | return [ 159 | 'id' => $scorm->getUuid(), 160 | 'version' => $scorm->getVersion(), 161 | 'title' => $scorm->getTitle(), 162 | 'entryUrl' => $scorm->getEntryUrl(), 163 | 'ratio' => $scorm->getRatio(), 164 | 'scos' => $this->serializeScos($scorm), 165 | ]; 166 | } 167 | 168 | private function serializeScos(Scorm $scorm) 169 | { 170 | return array_map(function (Sco $sco) { 171 | return $sco->serialize($sco); 172 | }, $scorm->getRootScos()); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Scorm Handler 2 | If you want to be a collaborator, feel free to raise a ticket and mention your email. 3 | 4 | ## _Design for Laravel LMS_ 5 | 6 | [![N|Solid](https://peopleaps.com/wp-content/uploads/2020/11/p2-01-01.png)](https://www.peopleaps.com) 7 | 8 | 9 | Laravel Scorm Handler is a laravel package that simplify scorm package contents (zip file) into laravel storage. 10 | 11 | Highlight of this package: 12 | - Zipfile handler with auto extract and store sco into database 13 | - Store user CMI data into database 14 | - Get user last learning data 15 | 16 | ## _Things you must know before you install:_ 17 | 1) You have a domain/subdomain to serve scorm content 18 | 2) Scorm content folder/path must be outside from laravel application (Security issue). 19 | 3) Virtual host to point domain/subdomain to scorm content directory (E.g: /scorm/hashed_folder_name/) 20 | 4) Uploaded file should have the right permission to extract scorm files into scorm content directory 21 | 5) This package will handle folder creation into scorm content directory (E.g: /scorm/{auto_generated_hashname}/imsmanifest.xml) 22 | 23 | 24 | ## Step 1: 25 | Install from composer (For flysystem v1) 26 | ```sh 27 | composer require devianl2/laravel-scorm:"^3.0" 28 | ``` 29 | 30 | Install from composer (For flysystem v2/v3) 31 | ```sh 32 | composer require devianl2/laravel-scorm 33 | ``` 34 | 35 | ## Step 2: 36 | Run vendor publish for migration and config file 37 | ```sh 38 | php artisan vendor:publish --provider="Peopleaps\Scorm\ScormServiceProvider" 39 | ``` 40 | 41 | ## Step 3: 42 | Run config cache for update cached configuration 43 | ```sh 44 | php artisan config:cache 45 | ``` 46 | 47 | ## Step 4: 48 | Migrate file to database 49 | ```sh 50 | php artisan migrate 51 | ``` 52 | 53 | ## Step 5 (Optional): 54 | ***Update SCORM config under `config/scorm`*** 55 | - update scorm table names. 56 | - update SCORM disk and configure disk @see config/filesystems.php 57 | ``` 58 | 'disk' => 'scorm-local', 59 | 'disk' => 'scorm-s3', 60 | 61 | // @see config/filesystems.php 62 | 'disks' => [ 63 | ..... 64 | 'scorm-local' => [ 65 | 'driver' => 'local', 66 | 'root' => env('SCORM_ROOT_DIR'), // set root dir 67 | 'visibility' => 'public', 68 | ], 69 | 70 | 's3-scorm' => [ 71 | 'driver' => 's3', 72 | 'root' => env('SCORM_ROOT_DIR'), // set root dir 73 | 'key' => env('AWS_ACCESS_KEY_ID'), 74 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 75 | 'region' => env('AWS_DEFAULT_REGION'), 76 | 'bucket' => env('AWS_SCORM_BUCKET'), 77 | ], 78 | ..... 79 | ] 80 | ``` 81 | ***Update SCORM translations under `resources/lang/en-US/scorm.php`*** 82 | - SCORM runtime errors exceptions handler, *(Check next example)* 83 | - Copy and translate error msg with key for other locale as you wish. 84 | 85 | *After finishing don't forget to run `php artisan config:cache`* 86 | 87 | 88 | ## Step 6 (Optional): 89 | 90 | **Usage** 91 | ``` 92 | class ScormController extends BaseController 93 | { 94 | /** @var ScormManager $scormManager */ 95 | private $scormManager; 96 | /** 97 | * ScormController constructor. 98 | * @param ScormManager $scormManager 99 | */ 100 | public function __construct(ScormManager $scormManager) 101 | { 102 | $this->scormManager = $scormManager; 103 | } 104 | 105 | public function show($id) 106 | { 107 | $item = ScormModel::with('scos')->findOrFail($id); 108 | // response helper function from base controller reponse json. 109 | return $this->respond($item); 110 | } 111 | 112 | public function store(ScormRequest $request) 113 | { 114 | try { 115 | $scorm = $this->scormManager->uploadScormArchive($request->file('file')); 116 | // handle scorm runtime error msg 117 | } catch (InvalidScormArchiveException | StorageNotFoundException $ex) { 118 | return $this->respondCouldNotCreateResource(trans('scorm.' . $ex->getMessage())); 119 | } 120 | 121 | // response helper function from base controller reponse json. 122 | return $this->respond(ScormModel::with('scos')->whereUuid($scorm['uuid'])->first()); 123 | } 124 | 125 | public function saveProgress(Request $request) 126 | { 127 | // TODO save user progress... 128 | } 129 | } 130 | ``` 131 | 132 | ***Upgrade from version 2 to 3:*** 133 | Update your Scorm table: 134 | - Add entry_url (varchar 191 / nullable) 135 | - Change hash_name to title 136 | - Remove origin_file_mime field 137 | 138 | ***Upgrade from version 3 to 4:*** 139 | Update your Scorm table: 140 | - Add identifier (varchar 191) 141 | 142 | -------------------------------------------------------------------------------- /database/migrations/create_scorm_tables.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 25 | $table->morphs($tableNames['resource_table']); 26 | $table->string('title'); 27 | $table->string('origin_file')->nullable(); 28 | $table->string('version'); 29 | $table->double('ratio')->nullable(); 30 | $table->string('uuid'); 31 | $table->string('identifier'); 32 | $table->string('entry_url')->nullable(); 33 | $table->timestamps(); 34 | }); 35 | 36 | // scorm_sco_model 37 | Schema::create($tableNames['scorm_sco_table'], function (Blueprint $table) use ($tableNames) { 38 | $table->bigIncrements('id'); 39 | $table->bigInteger('scorm_id')->unsigned(); 40 | $table->string('uuid'); 41 | $table->bigInteger('sco_parent_id')->unsigned()->nullable(); 42 | $table->string('entry_url')->nullable(); 43 | $table->string('identifier'); 44 | $table->string('title'); 45 | $table->tinyInteger('visible'); 46 | $table->longText('sco_parameters')->nullable(); 47 | $table->longText('launch_data')->nullable(); 48 | $table->string('max_time_allowed')->nullable(); 49 | $table->string('time_limit_action')->nullable(); 50 | $table->tinyInteger('block'); 51 | $table->integer('score_int')->nullable(); 52 | $table->decimal('score_decimal', 10,7)->nullable(); 53 | $table->decimal('completion_threshold', 10,7)->nullable(); 54 | $table->string('prerequisites')->nullable(); 55 | $table->timestamps(); 56 | 57 | $table->foreign('scorm_id')->references('id')->on($tableNames['scorm_table']); 58 | }); 59 | 60 | // scorm_sco_tracking_model 61 | Schema::create($tableNames['scorm_sco_tracking_table'], function (Blueprint $table) use ($tableNames) { 62 | $table->bigIncrements('id'); 63 | $table->bigInteger('user_id')->unsigned(); 64 | $table->bigInteger('sco_id')->unsigned(); 65 | $table->string('uuid'); 66 | $table->double('progression'); 67 | $table->integer('score_raw')->nullable(); 68 | $table->integer('score_min')->nullable(); 69 | $table->integer('score_max')->nullable(); 70 | $table->decimal('score_scaled', 10,7)->nullable(); 71 | $table->string('lesson_status')->nullable(); 72 | $table->string('completion_status')->nullable(); 73 | $table->integer('session_time')->nullable(); 74 | $table->integer('total_time_int')->nullable(); 75 | $table->string('total_time_string')->nullable(); 76 | $table->string('entry')->nullable(); 77 | $table->longText('suspend_data')->nullable(); 78 | $table->string('credit')->nullable(); 79 | $table->string('exit_mode')->nullable(); 80 | $table->string('lesson_location')->nullable(); 81 | $table->string('lesson_mode')->nullable(); 82 | $table->tinyInteger('is_locked')->nullable(); 83 | $table->longText('details')->comment('json_array')->nullable(); 84 | $table->dateTime('latest_date')->nullable(); 85 | $table->timestamps(); 86 | 87 | $table->foreign('user_id')->references('id')->on($tableNames['user_table']); 88 | $table->foreign('sco_id')->references('id')->on($tableNames['scorm_sco_table']); 89 | }); 90 | 91 | 92 | } 93 | 94 | /** 95 | * Reverse the migrations. 96 | * 97 | * @return void 98 | */ 99 | public function down() 100 | { 101 | $tableNames = config('scorm.table_names'); 102 | 103 | if (empty($tableNames)) { 104 | throw new \Exception('Error: Table not found.'); 105 | } 106 | 107 | Schema::drop($tableNames['scorm_sco_tracking_table']); 108 | Schema::drop($tableNames['scorm_sco_table']); 109 | Schema::drop($tableNames['scorm_table']); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Manager/ScormDisk.php: -------------------------------------------------------------------------------- 1 | cleanPath($target_dir); 25 | $unzipper = resolve(\ZipArchive::class); 26 | if ($unzipper->open($file)) { 27 | /** @var FilesystemAdapter $disk */ 28 | $disk = $this->getDisk(); 29 | for ($i = 0; $i < $unzipper->numFiles; ++$i) { 30 | $zipEntryName = $unzipper->getNameIndex($i); 31 | $destination = $this->join($target_dir, $this->cleanPath($zipEntryName)); 32 | if ($this->isDirectory($zipEntryName)) { 33 | $disk->createDirectory($destination); 34 | continue; 35 | } 36 | $disk->writeStream($destination, $unzipper->getStream($zipEntryName)); 37 | } 38 | return true; 39 | } 40 | return false; 41 | } 42 | 43 | /** 44 | * @param string $file SCORM archive uri on storage. 45 | * @param callable $fn function run user stuff before unlink 46 | */ 47 | public function readScormArchive($file, callable $fn) 48 | { 49 | try { 50 | $archiveDisk = $this->getArchiveDisk(); 51 | 52 | // Log the file path being processed for debugging 53 | Log::info('Processing SCORM archive file: ' . $file); 54 | 55 | // Check if file exists on archive disk 56 | if (!$archiveDisk->exists($file)) { 57 | Log::error('File not found on archive disk: ' . $file); 58 | throw new StorageNotFoundException('scorm_archive_not_found_on_archive_disk: ' . $file); 59 | } 60 | 61 | // Get the stream from archive disk 62 | $stream = $archiveDisk->readStream($file); 63 | if (!is_resource($stream)) { 64 | Log::error('Failed to read stream from archive disk for file: ' . $file . '. Stream type: ' . gettype($stream)); 65 | throw new StorageNotFoundException('failed_to_read_scorm_archive_stream: ' . $file); 66 | } 67 | 68 | if (Storage::exists($file)) { 69 | Storage::delete($file); 70 | } 71 | 72 | Storage::writeStream($file, $stream); 73 | $path = Storage::path($file); 74 | call_user_func($fn, $path); 75 | // Clean local resources 76 | $this->clean($file); 77 | } catch (Exception $ex) { 78 | Log::error('Error in readScormArchive: ' . $ex->getMessage() . ' for file: ' . $file); 79 | throw $ex; 80 | } 81 | } 82 | 83 | private function clean($file) 84 | { 85 | try { 86 | Storage::delete($file); 87 | Storage::deleteDirectory(dirname($file)); // delete temp dir 88 | } catch (Exception $ex) { 89 | Log::error($ex->getMessage()); 90 | } 91 | } 92 | 93 | /** 94 | * @param string $directory 95 | * @return bool 96 | */ 97 | public function deleteScorm($uuid) 98 | { 99 | $this->deleteScormArchive($uuid); // try to delete archive if exists. 100 | return $this->deleteScormContent($uuid); 101 | } 102 | 103 | /** 104 | * @param string $directory 105 | * @return bool 106 | */ 107 | private function deleteScormContent($folderHashedName) 108 | { 109 | try { 110 | return $this->getDisk()->deleteDirectory($folderHashedName); 111 | } catch (Exception $ex) { 112 | Log::error($ex->getMessage()); 113 | } 114 | } 115 | 116 | /** 117 | * @param string $directory 118 | * @return bool 119 | */ 120 | private function deleteScormArchive($uuid) 121 | { 122 | try { 123 | return $this->getArchiveDisk()->deleteDirectory($uuid); 124 | } catch (Exception $ex) { 125 | Log::error($ex->getMessage()); 126 | } 127 | } 128 | 129 | /** 130 | * 131 | * @param array $paths 132 | * @return string joined path 133 | */ 134 | private function join(...$paths) 135 | { 136 | return implode(DIRECTORY_SEPARATOR, $paths); 137 | } 138 | 139 | private function isDirectory($zipEntryName) 140 | { 141 | return substr($zipEntryName, -1) === '/'; 142 | } 143 | 144 | private function cleanPath($path) 145 | { 146 | return str_replace('/', DIRECTORY_SEPARATOR, $path); 147 | } 148 | 149 | /** 150 | * @return FilesystemAdapter $disk 151 | */ 152 | private function getDisk() 153 | { 154 | $diskName = config('scorm.disk'); 155 | if (empty($diskName)) { 156 | throw new StorageNotFoundException('scorm_disk_not_configured'); 157 | } 158 | 159 | if (!config()->has('filesystems.disks.' . $diskName)) { 160 | throw new StorageNotFoundException('scorm_disk_not_define: ' . $diskName); 161 | } 162 | 163 | $disk = Storage::disk($diskName); 164 | 165 | return $disk; 166 | } 167 | 168 | /** 169 | * @return FilesystemAdapter $disk 170 | */ 171 | private function getArchiveDisk() 172 | { 173 | $archiveDiskName = config('scorm.archive'); 174 | if (empty($archiveDiskName)) { 175 | throw new StorageNotFoundException('scorm_archive_disk_not_configured'); 176 | } 177 | 178 | if (!config()->has('filesystems.disks.' . $archiveDiskName)) { 179 | throw new StorageNotFoundException('scorm_archive_disk_not_define: ' . $archiveDiskName); 180 | } 181 | 182 | $disk = Storage::disk($archiveDiskName); 183 | 184 | return $disk; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Entity/Sco.php: -------------------------------------------------------------------------------- 1 | $this->getId(), 31 | 'scorm' => $this->getScorm(), 32 | 'scoParent' => $this->getScoParent(), 33 | 'entryUrl' => $this->getEntryUrl(), 34 | 'identifier' => $this->getIdentifier(), 35 | 36 | ]; 37 | } 38 | 39 | public function getUuid() 40 | { 41 | return $this->uuid; 42 | } 43 | 44 | public function setUuid($uuid) 45 | { 46 | $this->uuid = $uuid; 47 | } 48 | 49 | public function getId() 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function setId($id) 55 | { 56 | $this->id = $id; 57 | } 58 | 59 | public function getScorm() 60 | { 61 | return $this->scorm; 62 | } 63 | 64 | public function setScorm(Scorm $scorm = null) 65 | { 66 | $this->scorm = $scorm; 67 | } 68 | 69 | public function getScoParent() 70 | { 71 | return $this->scoParent; 72 | } 73 | 74 | public function setScoParent(Sco $scoParent = null) 75 | { 76 | $this->scoParent = $scoParent; 77 | } 78 | 79 | public function getScoChildren() 80 | { 81 | return $this->scoChildren; 82 | } 83 | 84 | public function setScoChildren($scoChildren) 85 | { 86 | $this->scoChildren = $scoChildren; 87 | } 88 | 89 | public function getEntryUrl() 90 | { 91 | return $this->entryUrl; 92 | } 93 | 94 | public function setEntryUrl($entryUrl) 95 | { 96 | $this->entryUrl = $entryUrl; 97 | } 98 | 99 | public function getIdentifier() 100 | { 101 | return $this->identifier; 102 | } 103 | 104 | public function setIdentifier($identifier) 105 | { 106 | $this->identifier = $identifier; 107 | } 108 | 109 | public function getTitle() 110 | { 111 | return $this->title; 112 | } 113 | 114 | public function setTitle($title) 115 | { 116 | $this->title = $title; 117 | } 118 | 119 | public function isVisible() 120 | { 121 | return $this->visible; 122 | } 123 | 124 | public function setVisible($visible) 125 | { 126 | $this->visible = $visible; 127 | } 128 | 129 | public function getParameters() 130 | { 131 | return $this->parameters; 132 | } 133 | 134 | public function setParameters($parameters) 135 | { 136 | $this->parameters = $parameters; 137 | } 138 | 139 | public function getLaunchData() 140 | { 141 | return $this->launchData; 142 | } 143 | 144 | public function setLaunchData($launchData) 145 | { 146 | $this->launchData = $launchData; 147 | } 148 | 149 | public function getMaxTimeAllowed() 150 | { 151 | return $this->maxTimeAllowed; 152 | } 153 | 154 | public function setMaxTimeAllowed($maxTimeAllowed) 155 | { 156 | $this->maxTimeAllowed = $maxTimeAllowed; 157 | } 158 | 159 | public function getTimeLimitAction() 160 | { 161 | return $this->timeLimitAction; 162 | } 163 | 164 | public function setTimeLimitAction($timeLimitAction) 165 | { 166 | $this->timeLimitAction = $timeLimitAction; 167 | } 168 | 169 | public function isBlock() 170 | { 171 | return $this->block; 172 | } 173 | 174 | public function setBlock($block) 175 | { 176 | $this->block = $block; 177 | } 178 | 179 | public function getScoreToPass() 180 | { 181 | if (Scorm::SCORM_2004 === $this->scorm->getVersion()) { 182 | return $this->scoreToPassDecimal; 183 | } else { 184 | return $this->scoreToPassInt; 185 | } 186 | } 187 | 188 | public function setScoreToPass($scoreToPass) 189 | { 190 | if (Scorm::SCORM_2004 === $this->scorm->getVersion()) { 191 | $this->setScoreToPassDecimal($scoreToPass); 192 | } else { 193 | $this->setScoreToPassInt($scoreToPass); 194 | } 195 | } 196 | 197 | public function getScoreToPassInt() 198 | { 199 | return $this->scoreToPassInt; 200 | } 201 | 202 | public function setScoreToPassInt($scoreToPassInt) 203 | { 204 | $this->scoreToPassInt = $scoreToPassInt; 205 | } 206 | 207 | public function getScoreToPassDecimal() 208 | { 209 | return $this->scoreToPassDecimal; 210 | } 211 | 212 | public function setScoreToPassDecimal($scoreToPassDecimal) 213 | { 214 | $this->scoreToPassDecimal = $scoreToPassDecimal; 215 | } 216 | 217 | public function getCompletionThreshold() 218 | { 219 | return $this->completionThreshold; 220 | } 221 | 222 | public function setCompletionThreshold($completionThreshold) 223 | { 224 | $this->completionThreshold = $completionThreshold; 225 | } 226 | 227 | public function getPrerequisites() 228 | { 229 | return $this->prerequisites; 230 | } 231 | 232 | public function setPrerequisites($prerequisites) 233 | { 234 | $this->prerequisites = $prerequisites; 235 | } 236 | 237 | /** 238 | * @return array 239 | */ 240 | public function serialize(Sco $sco) 241 | { 242 | $scorm = $sco->getScorm(); 243 | $parent = $sco->getScoParent(); 244 | 245 | return [ 246 | 'id' => $sco->getUuid(), 247 | 'scorm' => !empty($scorm) ? ['id' => $scorm->getUuid()] : null, 248 | 'data' => [ 249 | 'entryUrl' => $sco->getEntryUrl(), 250 | 'identifier' => $sco->getIdentifier(), 251 | 'title' => $sco->getTitle(), 252 | 'visible' => $sco->isVisible(), 253 | 'parameters' => $sco->getParameters(), 254 | 'launchData' => $sco->getLaunchData(), 255 | 'maxTimeAllowed' => $sco->getMaxTimeAllowed(), 256 | 'timeLimitAction' => $sco->getTimeLimitAction(), 257 | 'block' => $sco->isBlock(), 258 | 'scoreToPassInt' => $sco->getScoreToPassInt(), 259 | 'scoreToPassDecimal' => $sco->getScoreToPassDecimal(), 260 | 'scoreToPass' => !empty($scorm) ? $sco->getScoreToPass() : null, 261 | 'completionThreshold' => $sco->getCompletionThreshold(), 262 | 'prerequisites' => $sco->getPrerequisites(), 263 | ], 264 | 'parent' => !empty($parent) ? ['id' => $parent->getUuid()] : null, 265 | 'children' => array_map(function (Sco $scoChild) { 266 | return $this->serialize($scoChild); 267 | }, is_array($sco->getScoChildren()) ? $sco->getScoChildren() : $sco->getScoChildren()->toArray()), 268 | ]; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Entity/ScoTracking.php: -------------------------------------------------------------------------------- 1 | userId; 37 | } 38 | 39 | public function setUserId($userId) 40 | { 41 | $this->userId = $userId; 42 | } 43 | 44 | public function getUuid() 45 | { 46 | return $this->uuid; 47 | } 48 | 49 | public function setUuid($uuid) 50 | { 51 | $this->uuid = $uuid; 52 | } 53 | 54 | /** 55 | * @return Sco 56 | */ 57 | public function getSco() 58 | { 59 | return $this->sco; 60 | } 61 | 62 | public function setSco($sco) 63 | { 64 | $this->sco = $sco; 65 | } 66 | 67 | public function getScoreRaw() 68 | { 69 | return $this->scoreRaw; 70 | } 71 | 72 | public function setScoreRaw($scoreRaw) 73 | { 74 | $this->scoreRaw = $scoreRaw; 75 | } 76 | 77 | public function getScoreMin() 78 | { 79 | return $this->scoreMin; 80 | } 81 | 82 | public function setScoreMin($scoreMin) 83 | { 84 | $this->scoreMin = $scoreMin; 85 | } 86 | 87 | public function getScoreMax() 88 | { 89 | return $this->scoreMax; 90 | } 91 | 92 | public function setScoreMax($scoreMax) 93 | { 94 | $this->scoreMax = $scoreMax; 95 | } 96 | 97 | public function getScoreScaled() 98 | { 99 | return $this->scoreScaled; 100 | } 101 | 102 | public function setScoreScaled($scoreScaled) 103 | { 104 | $this->scoreScaled = $scoreScaled; 105 | } 106 | 107 | public function getLessonStatus() 108 | { 109 | return $this->lessonStatus; 110 | } 111 | 112 | public function setLessonStatus($lessonStatus) 113 | { 114 | $this->lessonStatus = $lessonStatus; 115 | } 116 | 117 | public function getCompletionStatus() 118 | { 119 | return $this->completionStatus; 120 | } 121 | 122 | public function setCompletionStatus($completionStatus) 123 | { 124 | $this->completionStatus = $completionStatus; 125 | } 126 | 127 | public function getSessionTime() 128 | { 129 | return $this->sessionTime; 130 | } 131 | 132 | public function setSessionTime($sessionTime) 133 | { 134 | $this->sessionTime = $sessionTime; 135 | } 136 | 137 | public function getTotalTime($scormVersion) 138 | { 139 | if (Scorm::SCORM_2004 === $scormVersion) { 140 | return $this->totalTimeString; 141 | } else { 142 | return $this->totalTimeInt; 143 | } 144 | } 145 | 146 | public function setTotalTime($totalTime, $scormVersion) 147 | { 148 | if (Scorm::SCORM_2004 === $scormVersion) { 149 | $this->setTotalTimeString($totalTime); 150 | } else { 151 | $this->setTotalTimeInt($totalTime); 152 | } 153 | } 154 | 155 | public function getTotalTimeInt() 156 | { 157 | return $this->totalTimeInt; 158 | } 159 | 160 | public function setTotalTimeInt($totalTimeInt) 161 | { 162 | $this->totalTimeInt = $totalTimeInt; 163 | } 164 | 165 | public function getTotalTimeString() 166 | { 167 | return $this->totalTimeString; 168 | } 169 | 170 | public function setTotalTimeString($totalTimeString) 171 | { 172 | $this->totalTimeString = $totalTimeString; 173 | } 174 | 175 | public function getEntry() 176 | { 177 | return $this->entry; 178 | } 179 | 180 | public function setEntry($entry) 181 | { 182 | $this->entry = $entry; 183 | } 184 | 185 | public function getSuspendData() 186 | { 187 | return $this->suspendData; 188 | } 189 | 190 | public function setSuspendData($suspendData) 191 | { 192 | $this->suspendData = $suspendData; 193 | } 194 | 195 | public function getCredit() 196 | { 197 | return $this->credit; 198 | } 199 | 200 | public function setCredit($credit) 201 | { 202 | $this->credit = $credit; 203 | } 204 | 205 | public function getExitMode() 206 | { 207 | return $this->exitMode; 208 | } 209 | 210 | public function setExitMode($exitMode) 211 | { 212 | $this->exitMode = $exitMode; 213 | } 214 | 215 | public function getLessonLocation() 216 | { 217 | return $this->lessonLocation; 218 | } 219 | 220 | public function setLessonLocation($lessonLocation) 221 | { 222 | $this->lessonLocation = $lessonLocation; 223 | } 224 | 225 | public function getLessonMode() 226 | { 227 | return $this->lessonMode; 228 | } 229 | 230 | public function setLessonMode($lessonMode) 231 | { 232 | $this->lessonMode = $lessonMode; 233 | } 234 | 235 | public function getIsLocked() 236 | { 237 | return $this->isLocked; 238 | } 239 | 240 | public function setIsLocked($isLocked) 241 | { 242 | $this->isLocked = $isLocked; 243 | } 244 | 245 | public function getDetails() 246 | { 247 | return $this->details; 248 | } 249 | 250 | public function setDetails($details) 251 | { 252 | $this->details = $details; 253 | } 254 | 255 | public function getLatestDate() 256 | { 257 | return $this->latestDate; 258 | } 259 | 260 | public function setLatestDate(Carbon $latestDate = null) 261 | { 262 | $this->latestDate = $latestDate; 263 | } 264 | 265 | public function getProgression() 266 | { 267 | return $this->progression; 268 | } 269 | 270 | public function setProgression($progression) 271 | { 272 | $this->progression = $progression; 273 | } 274 | 275 | public function getFormattedTotalTime() 276 | { 277 | if (Scorm::SCORM_2004 === $this->sco->getScorm()->getVersion()) { 278 | return $this->getFormattedTotalTimeString(); 279 | } else { 280 | return $this->getFormattedTotalTimeInt(); 281 | } 282 | } 283 | 284 | public function getFormattedTotalTimeInt() 285 | { 286 | $remainingTime = $this->totalTimeInt; 287 | $hours = intval($remainingTime / 360000); 288 | $remainingTime %= 360000; 289 | $minutes = intval($remainingTime / 6000); 290 | $remainingTime %= 6000; 291 | $seconds = intval($remainingTime / 100); 292 | $remainingTime %= 100; 293 | 294 | $formattedTime = ''; 295 | 296 | if ($hours < 10) { 297 | $formattedTime .= '0'; 298 | } 299 | $formattedTime .= $hours.':'; 300 | 301 | if ($minutes < 10) { 302 | $formattedTime .= '0'; 303 | } 304 | $formattedTime .= $minutes.':'; 305 | 306 | if ($seconds < 10) { 307 | $formattedTime .= '0'; 308 | } 309 | $formattedTime .= $seconds.'.'; 310 | 311 | if ($remainingTime < 10) { 312 | $formattedTime .= '0'; 313 | } 314 | $formattedTime .= $remainingTime; 315 | 316 | return $formattedTime; 317 | } 318 | 319 | public function getFormattedTotalTimeString() 320 | { 321 | $pattern = '/^P([0-9]+Y)?([0-9]+M)?([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+S)?$/'; 322 | $formattedTime = ''; 323 | 324 | if (!empty($this->totalTimeString) && 'PT' !== $this->totalTimeString && preg_match($pattern, $this->totalTimeString)) { 325 | $interval = new \DateInterval($this->totalTimeString); 326 | $time = new \DateTime(); 327 | $time->setTimestamp(0); 328 | $time->add($interval); 329 | $timeInSecond = $time->getTimestamp(); 330 | 331 | $hours = intval($timeInSecond / 3600); 332 | $timeInSecond %= 3600; 333 | $minutes = intval($timeInSecond / 60); 334 | $timeInSecond %= 60; 335 | 336 | if ($hours < 10) { 337 | $formattedTime .= '0'; 338 | } 339 | $formattedTime .= $hours.':'; 340 | 341 | if ($minutes < 10) { 342 | $formattedTime .= '0'; 343 | } 344 | $formattedTime .= $minutes.':'; 345 | 346 | if ($timeInSecond < 10) { 347 | $formattedTime .= '0'; 348 | } 349 | $formattedTime .= $timeInSecond; 350 | } else { 351 | $formattedTime .= '00:00:00'; 352 | } 353 | 354 | return $formattedTime; 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/Library/ScormLib.php: -------------------------------------------------------------------------------- 1 | getElementsByTagName('organizations'); 25 | $resources = $dom->getElementsByTagName('resource'); 26 | 27 | if ($organizationsList->length > 0) { 28 | $organizations = $organizationsList->item(0); 29 | $organization = $organizations->firstChild; 30 | 31 | if ( 32 | !is_null($organizations->attributes) 33 | && !is_null($organizations->attributes->getNamedItem('default')) 34 | ) { 35 | $defaultOrganization = $organizations->attributes->getNamedItem('default')->nodeValue; 36 | } else { 37 | $defaultOrganization = null; 38 | } 39 | // No default organization is defined 40 | if (is_null($defaultOrganization)) { 41 | while ( 42 | !is_null($organization) 43 | && 'organization' !== $organization->nodeName 44 | ) { 45 | $organization = $organization->nextSibling; 46 | } 47 | 48 | if (is_null($organization)) { 49 | return $this->parseResourceNodes($resources); 50 | } 51 | } 52 | // A default organization is defined 53 | // Look for it 54 | else { 55 | while ( 56 | !is_null($organization) 57 | && ('organization' !== $organization->nodeName 58 | || is_null($organization->attributes->getNamedItem('identifier')) 59 | || $organization->attributes->getNamedItem('identifier')->nodeValue !== $defaultOrganization) 60 | ) { 61 | $organization = $organization->nextSibling; 62 | } 63 | 64 | if (is_null($organization)) { 65 | throw new InvalidScormArchiveException('default_organization_not_found_message'); 66 | } 67 | } 68 | 69 | return $this->parseItemNodes($organization, $resources); 70 | } else { 71 | throw new InvalidScormArchiveException('no_organization_found_message'); 72 | } 73 | } 74 | 75 | /** 76 | * Creates defined structure of SCOs. 77 | * 78 | * @return array of Sco 79 | * 80 | * @throws InvalidScormArchiveException 81 | */ 82 | private function parseItemNodes(\DOMNode $source, \DOMNodeList $resources, Sco $parentSco = null) 83 | { 84 | $item = $source->firstChild; 85 | $scos = []; 86 | 87 | while (!is_null($item)) { 88 | if ('item' === $item->nodeName) { 89 | $sco = new Sco(); 90 | $scos[] = $sco; 91 | $sco->setUuid(Str::uuid()); 92 | $sco->setScoParent($parentSco); 93 | $this->findAttrParams($sco, $item, $resources); 94 | $this->findNodeParams($sco, $item->firstChild); 95 | 96 | if ($sco->isBlock()) { 97 | $sco->setScoChildren($this->parseItemNodes($item, $resources, $sco)); 98 | } 99 | } 100 | $item = $item->nextSibling; 101 | } 102 | 103 | return $scos; 104 | } 105 | 106 | private function parseResourceNodes(\DOMNodeList $resources) 107 | { 108 | $scos = []; 109 | 110 | foreach ($resources as $resource) { 111 | if (!is_null($resource->attributes)) { 112 | $scormType = $resource->attributes->getNamedItemNS( 113 | $resource->lookupNamespaceUri('adlcp'), 114 | 'scormType' 115 | ); 116 | 117 | if (!is_null($scormType) && 'sco' === $scormType->nodeValue) { 118 | $identifier = $resource->attributes->getNamedItem('identifier'); 119 | $href = $resource->attributes->getNamedItem('href'); 120 | 121 | if (is_null($identifier)) { 122 | throw new InvalidScormArchiveException('sco_with_no_identifier_message'); 123 | } 124 | if (is_null($href)) { 125 | throw new InvalidScormArchiveException('sco_resource_without_href_message'); 126 | } 127 | $sco = new Sco(); 128 | $sco->setUuid(Str::uuid()); 129 | $sco->setBlock(false); 130 | $sco->setVisible(true); 131 | $sco->setIdentifier($identifier->nodeValue); 132 | $sco->setTitle($identifier->nodeValue); 133 | $sco->setEntryUrl($href->nodeValue); 134 | $scos[] = $sco; 135 | } 136 | } 137 | } 138 | 139 | return $scos; 140 | } 141 | 142 | /** 143 | * Initializes parameters of the SCO defined in attributes of the node. 144 | * It also look for the associated resource if it is a SCO and not a block. 145 | * 146 | * @throws InvalidScormArchiveException 147 | */ 148 | private function findAttrParams(Sco $sco, \DOMNode $item, \DOMNodeList $resources) 149 | { 150 | $identifier = $item->attributes->getNamedItem('identifier'); 151 | $isVisible = $item->attributes->getNamedItem('isvisible'); 152 | $identifierRef = $item->attributes->getNamedItem('identifierref'); 153 | $parameters = $item->attributes->getNamedItem('parameters'); 154 | 155 | // throws an Exception if identifier is undefined 156 | if (is_null($identifier)) { 157 | throw new InvalidScormArchiveException('sco_with_no_identifier_message'); 158 | } 159 | $sco->setIdentifier($identifier->nodeValue); 160 | 161 | // visible is true by default 162 | if (!is_null($isVisible) && 'false' === $isVisible) { 163 | $sco->setVisible(false); 164 | } else { 165 | $sco->setVisible(true); 166 | } 167 | 168 | // set parameters for SCO entry resource 169 | if (!is_null($parameters)) { 170 | $sco->setParameters($parameters->nodeValue); 171 | } 172 | 173 | // check if item is a block or a SCO. A block doesn't define identifierref 174 | if (is_null($identifierRef)) { 175 | $sco->setBlock(true); 176 | } else { 177 | $sco->setBlock(false); 178 | // retrieve entry URL 179 | $sco->setEntryUrl($this->findEntryUrl($identifierRef->nodeValue, $resources)); 180 | } 181 | } 182 | 183 | /** 184 | * Initializes parameters of the SCO defined in children nodes. 185 | */ 186 | private function findNodeParams(Sco $sco, \DOMNode $item) 187 | { 188 | while (!is_null($item)) { 189 | switch ($item->nodeName) { 190 | case 'title': 191 | $sco->setTitle($item->nodeValue); 192 | break; 193 | case 'adlcp:masteryscore': 194 | $sco->setScoreToPassInt($item->nodeValue); 195 | break; 196 | case 'adlcp:maxtimeallowed': 197 | case 'imsss:attemptAbsoluteDurationLimit': 198 | $sco->setMaxTimeAllowed($item->nodeValue); 199 | break; 200 | case 'adlcp:timelimitaction': 201 | case 'adlcp:timeLimitAction': 202 | $action = strtolower($item->nodeValue); 203 | 204 | if ( 205 | 'exit,message' === $action 206 | || 'exit,no message' === $action 207 | || 'continue,message' === $action 208 | || 'continue,no message' === $action 209 | ) { 210 | $sco->setTimeLimitAction($action); 211 | } 212 | break; 213 | case 'adlcp:datafromlms': 214 | case 'adlcp:dataFromLMS': 215 | $sco->setLaunchData($item->nodeValue); 216 | break; 217 | case 'adlcp:prerequisites': 218 | $sco->setPrerequisites($item->nodeValue); 219 | break; 220 | case 'imsss:minNormalizedMeasure': 221 | $sco->setScoreToPassDecimal($item->nodeValue); 222 | break; 223 | case 'adlcp:completionThreshold': 224 | if ($item->nodeValue && !is_nan($item->nodeValue)) { 225 | $sco->setCompletionThreshold(floatval($item->nodeValue)); 226 | } 227 | break; 228 | } 229 | $item = $item->nextSibling; 230 | } 231 | } 232 | 233 | /** 234 | * Searches for the resource with the given id and retrieve URL to its content. 235 | * 236 | * @return string URL to the resource associated to the SCO 237 | * 238 | * @throws InvalidScormArchiveException 239 | */ 240 | public function findEntryUrl($identifierref, \DOMNodeList $resources) 241 | { 242 | foreach ($resources as $resource) { 243 | $identifier = $resource->attributes->getNamedItem('identifier'); 244 | 245 | if (!is_null($identifier)) { 246 | $identifierValue = $identifier->nodeValue; 247 | 248 | if ($identifierValue === $identifierref) { 249 | $href = $resource->attributes->getNamedItem('href'); 250 | 251 | if (is_null($href)) { 252 | throw new InvalidScormArchiveException('sco_resource_without_href_message'); 253 | } 254 | 255 | return $href->nodeValue; 256 | } 257 | } 258 | } 259 | throw new InvalidScormArchiveException('sco_without_resource_message'); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/Manager/ScormManager.php: -------------------------------------------------------------------------------- 1 | scormLib = new ScormLib(); 39 | $this->scormDisk = new ScormDisk(); 40 | } 41 | 42 | public function uploadScormFromUri($file, $uuid = null) 43 | { 44 | // $uuid is meant for user to update scorm content. Hence, if user want to update content should parse in existing uuid 45 | $this->uuid = $uuid ?? Str::uuid()->toString(); 46 | 47 | // Validate that the file parameter is not empty 48 | if (empty($file)) { 49 | throw new InvalidScormArchiveException('file_parameter_empty'); 50 | } 51 | 52 | // Log the file being processed for debugging 53 | \Log::info('Uploading SCORM from URI: ' . $file); 54 | 55 | $scorm = null; 56 | $this->scormDisk->readScormArchive($file, function ($path) use (&$scorm, $file, $uuid) { 57 | $filename = basename($file); 58 | $scorm = $this->saveScorm($path, $filename, $uuid); 59 | }); 60 | return $scorm; 61 | } 62 | 63 | /** 64 | * @param UploadedFile $file 65 | * @param null|string $uuid 66 | * @return ScormModel 67 | * @throws InvalidScormArchiveException 68 | */ 69 | public function uploadScormArchive(UploadedFile $file, $uuid = null) 70 | { 71 | // $uuid is meant for user to update scorm content. Hence, if user want to update content should parse in existing uuid 72 | $this->uuid = $uuid ?? Str::uuid()->toString(); 73 | 74 | return $this->saveScorm($file, $file->getClientOriginalName(), $uuid); 75 | } 76 | 77 | /** 78 | * Checks if it is a valid scorm archive 79 | * 80 | * @param string|UploadedFile $file zip. 81 | */ 82 | private function validatePackage($file) 83 | { 84 | $zip = new \ZipArchive(); 85 | $openValue = $zip->open($file); 86 | $isScormArchive = (true === $openValue) && $zip->getStream('imsmanifest.xml'); 87 | 88 | $zip->close(); 89 | if (!$isScormArchive) { 90 | $this->onError('invalid_scorm_archive_message'); 91 | } 92 | } 93 | 94 | /** 95 | * Save scorm data 96 | * 97 | * @param string|UploadedFile $file zip. 98 | * @param string $filename 99 | * @param null|string $uuid 100 | * @return ScormModel 101 | * @throws InvalidScormArchiveException 102 | */ 103 | private function saveScorm($file, $filename, $uuid = null) 104 | { 105 | $this->validatePackage($file); 106 | $scormData = $this->generateScorm($file); 107 | // save to db 108 | if (is_null($scormData) || !is_array($scormData)) { 109 | $this->onError('invalid_scorm_data'); 110 | } 111 | 112 | // This uuid is use when the admin wants to edit existing scorm file. 113 | if (!empty($uuid)) { 114 | $this->uuid = $uuid; // Overwrite system generated uuid 115 | } 116 | 117 | /** 118 | * ScormModel::whereUuid Query Builder style equals ScormModel::where('uuid',$value) 119 | * 120 | * From Laravel doc https://laravel.com/docs/5.0/queries#advanced-wheres. 121 | * Dynamic Where Clauses 122 | * You may even use "dynamic" where statements to fluently build where statements using magic methods: 123 | * 124 | * Examples: 125 | * 126 | * $admin = DB::table('users')->whereId(1)->first(); 127 | * From laravel framework https://github.com/laravel/framework/blob/9.x/src/Illuminate/Database/Query/Builder.php' 128 | * Handle dynamic method calls into the method. 129 | * return $this->dynamicWhere($method, $parameters); 130 | **/ 131 | // $scorm = ScormModel::whereOriginFile($filename); 132 | // Uuid indicator is better than filename for update content or add new content. 133 | $scorm = ScormModel::where('uuid', $this->uuid); 134 | 135 | // Check if scom package already exists to drop old one. 136 | if (!$scorm->exists()) { 137 | $scorm = new ScormModel(); 138 | } else { 139 | $scorm = $scorm->first(); 140 | $this->deleteScormData($scorm); 141 | } 142 | 143 | $scorm->uuid = $this->uuid; 144 | $scorm->title = $scormData['title']; 145 | $scorm->version = $scormData['version']; 146 | $scorm->entry_url = $scormData['entryUrl']; 147 | $scorm->identifier = $scormData['identifier']; 148 | $scorm->origin_file = $filename; 149 | $scorm->save(); 150 | 151 | if (!empty($scormData['scos']) && is_array($scormData['scos'])) { 152 | /** @var Sco $scoData */ 153 | foreach ($scormData['scos'] as $scoData) { 154 | $sco = $this->saveScormScos($scorm->id, $scoData); 155 | if ($scoData->scoChildren) { 156 | foreach ($scoData->scoChildren as $scoChild) { 157 | $this->saveScormScos($scorm->id, $scoChild, $sco->id); 158 | } 159 | } 160 | } 161 | } 162 | 163 | return $scorm; 164 | } 165 | 166 | /** 167 | * Save Scorm sco and it's nested children 168 | * @param int $scorm_id scorm id. 169 | * @param Sco $scoData Sco data to be store. 170 | * @param int $sco_parent_id sco parent id for children 171 | */ 172 | private function saveScormScos($scorm_id, $scoData, $sco_parent_id = null) 173 | { 174 | $sco = new ScormScoModel(); 175 | $sco->scorm_id = $scorm_id; 176 | $sco->uuid = $scoData->uuid; 177 | $sco->sco_parent_id = $sco_parent_id; 178 | $sco->entry_url = $scoData->entryUrl; 179 | $sco->identifier = $scoData->identifier; 180 | $sco->title = $scoData->title; 181 | $sco->visible = $scoData->visible; 182 | $sco->sco_parameters = $scoData->parameters; 183 | $sco->launch_data = $scoData->launchData; 184 | $sco->max_time_allowed = $scoData->maxTimeAllowed; 185 | $sco->time_limit_action = $scoData->timeLimitAction; 186 | $sco->block = $scoData->block; 187 | $sco->score_int = $scoData->scoreToPassInt; 188 | $sco->score_decimal = $scoData->scoreToPassDecimal; 189 | $sco->completion_threshold = $scoData->completionThreshold; 190 | $sco->prerequisites = $scoData->prerequisites; 191 | $sco->save(); 192 | return $sco; 193 | } 194 | 195 | /** 196 | * @param string|UploadedFile $file zip. 197 | */ 198 | private function parseScormArchive($file) 199 | { 200 | $data = []; 201 | $contents = ''; 202 | $zip = new \ZipArchive(); 203 | 204 | $zip->open($file); 205 | $stream = $zip->getStream('imsmanifest.xml'); 206 | 207 | while (!feof($stream)) { 208 | $contents .= fread($stream, 2); 209 | } 210 | 211 | 212 | $dom = new DOMDocument(); 213 | 214 | if (!$dom->loadXML($contents)) { 215 | $this->onError('cannot_load_imsmanifest_message'); 216 | } 217 | 218 | $manifest = $dom->getElementsByTagName('manifest')->item(0); 219 | if (!is_null($manifest->attributes->getNamedItem('identifier'))) { 220 | $data['identifier'] = $manifest->attributes->getNamedItem('identifier')->nodeValue; 221 | } else { 222 | $this->onError('invalid_scorm_manifest_identifier'); 223 | } 224 | $titles = $dom->getElementsByTagName('title'); 225 | if ($titles->length > 0) { 226 | $data['title'] = Str::of($titles->item(0)->textContent)->trim('/n')->trim(); 227 | } 228 | 229 | $scormVersionElements = $dom->getElementsByTagName('schemaversion'); 230 | if ($scormVersionElements->length > 0) { 231 | switch ($scormVersionElements->item(0)->textContent) { 232 | case '1.2': 233 | $data['version'] = Scorm::SCORM_12; 234 | break; 235 | case 'CAM 1.3': 236 | case '2004 3rd Edition': 237 | case '2004 4th Edition': 238 | $data['version'] = Scorm::SCORM_2004; 239 | break; 240 | default: 241 | $this->onError('invalid_scorm_version_message'); 242 | } 243 | } else { 244 | $this->onError('invalid_scorm_version_message'); 245 | } 246 | $scos = $this->scormLib->parseOrganizationsNode($dom); 247 | 248 | if (0 >= count($scos)) { 249 | $this->onError('no_sco_in_scorm_archive_message'); 250 | } 251 | 252 | $data['entryUrl'] = $scos[0]->entryUrl ?? $scos[0]->scoChildren[0]->entryUrl; 253 | $data['scos'] = $scos; 254 | 255 | return $data; 256 | } 257 | 258 | public function deleteScorm($model) 259 | { 260 | // Delete after the previous item is stored 261 | if ($model) { 262 | $this->deleteScormData($model); 263 | // Delete folder from server 264 | $this->deleteScormFolder($model->uuid); 265 | $model->delete(); // delete scorm 266 | } 267 | } 268 | 269 | private function deleteScormData($model) 270 | { 271 | // Delete after the previous item is stored 272 | $oldScos = $model->scos()->get(); 273 | 274 | // Delete all tracking associate with sco 275 | foreach ($oldScos as $oldSco) { 276 | $oldSco->scoTrackings()->delete(); 277 | } 278 | $model->scos()->delete(); // delete scos 279 | } 280 | 281 | /** 282 | * @param $folderHashedName 283 | * @return bool 284 | */ 285 | protected function deleteScormFolder($folderHashedName) 286 | { 287 | return $this->scormDisk->deleteScorm($folderHashedName); 288 | } 289 | 290 | /** 291 | * @param string|UploadedFile $file zip. 292 | * @return array 293 | * @throws InvalidScormArchiveException 294 | */ 295 | private function generateScorm($file) 296 | { 297 | $scormData = $this->parseScormArchive($file); 298 | /** 299 | * Unzip a given ZIP file into the web resources directory. 300 | * 301 | * @param string $hashName name of the destination directory 302 | */ 303 | $this->scormDisk->unzipper($file, $this->uuid); 304 | 305 | return [ 306 | 'identifier' => $scormData['identifier'], 307 | 'uuid' => $this->uuid, 308 | 'title' => $scormData['title'], // to follow standard file data format 309 | 'version' => $scormData['version'], 310 | 'entryUrl' => $scormData['entryUrl'], 311 | 'scos' => $scormData['scos'], 312 | ]; 313 | } 314 | 315 | /** 316 | * Get SCO list 317 | * @param $scormId 318 | * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection 319 | */ 320 | public function getScos($scormId) 321 | { 322 | $scos = ScormScoModel::with([ 323 | 'scorm' 324 | ])->where('scorm_id', $scormId) 325 | ->get(); 326 | 327 | return $scos; 328 | } 329 | 330 | /** 331 | * Get sco by uuid 332 | * @param $scoUuid 333 | * @return null|\Illuminate\Database\Eloquent\Builder|Model 334 | */ 335 | public function getScoByUuid($scoUuid) 336 | { 337 | $sco = ScormScoModel::with(['scorm']) 338 | ->where('uuid', $scoUuid) 339 | ->firstOrFail(); 340 | 341 | return $sco; 342 | } 343 | 344 | public function getUserResult($scoId, $userId) 345 | { 346 | return ScormScoTrackingModel::where('sco_id', $scoId)->where('user_id', $userId)->first(); 347 | } 348 | 349 | public function createScoTracking($scoUuid, $userId = null, $userName = null) 350 | { 351 | $sco = ScormScoModel::where('uuid', $scoUuid)->firstOrFail(); 352 | 353 | $version = $sco->scorm->version; 354 | $scoTracking = new ScoTracking(); 355 | $scoTracking->setSco($sco->toArray()); 356 | 357 | $cmi = null; 358 | switch ($version) { 359 | case Scorm::SCORM_12: 360 | $scoTracking->setLessonStatus('not attempted'); 361 | $scoTracking->setSuspendData(''); 362 | $scoTracking->setEntry('ab-initio'); 363 | $scoTracking->setLessonLocation(''); 364 | $scoTracking->setCredit('no-credit'); 365 | $scoTracking->setTotalTimeInt(0); 366 | $scoTracking->setSessionTime(0); 367 | $scoTracking->setLessonMode('normal'); 368 | $scoTracking->setExitMode(''); 369 | 370 | if (is_null($sco->prerequisites)) { 371 | $scoTracking->setIsLocked(false); 372 | } else { 373 | $scoTracking->setIsLocked(true); 374 | } 375 | $cmi = [ 376 | 'cmi.core.entry' => $scoTracking->getEntry(), 377 | 'cmi.core.student_id' => $userId, 378 | 'cmi.core.student_name' => $userName, 379 | ]; 380 | 381 | break; 382 | case Scorm::SCORM_2004: 383 | $scoTracking->setTotalTimeString('PT0S'); 384 | $scoTracking->setCompletionStatus('unknown'); 385 | $scoTracking->setLessonStatus('unknown'); 386 | $scoTracking->setIsLocked(false); 387 | $cmi = [ 388 | 'cmi.entry' => 'ab-initio', 389 | 'cmi.learner_id' => $userId, 390 | 'cmi.learner_name' => $userName, 391 | 'cmi.scaled_passing_score' => 0.5, 392 | ]; 393 | break; 394 | } 395 | 396 | $scoTracking->setUserId($userId); 397 | $scoTracking->setDetails($cmi); 398 | 399 | // Create a new tracking model 400 | $storeTracking = ScormScoTrackingModel::firstOrCreate([ 401 | 'user_id' => $userId, 402 | 'sco_id' => $sco->id 403 | ], [ 404 | 'uuid' => Str::uuid()->toString(), 405 | 'progression' => $scoTracking->getProgression(), 406 | 'score_raw' => $scoTracking->getScoreRaw(), 407 | 'score_min' => $scoTracking->getScoreMin(), 408 | 'score_max' => $scoTracking->getScoreMax(), 409 | 'score_scaled' => $scoTracking->getScoreScaled(), 410 | 'lesson_status' => $scoTracking->getLessonStatus(), 411 | 'completion_status' => $scoTracking->getCompletionStatus(), 412 | 'session_time' => $scoTracking->getSessionTime(), 413 | 'total_time_int' => $scoTracking->getTotalTimeInt(), 414 | 'total_time_string' => $scoTracking->getTotalTimeString(), 415 | 'entry' => $scoTracking->getEntry(), 416 | 'suspend_data' => $scoTracking->getSuspendData(), 417 | 'credit' => $scoTracking->getCredit(), 418 | 'exit_mode' => $scoTracking->getExitMode(), 419 | 'lesson_location' => $scoTracking->getLessonLocation(), 420 | 'lesson_mode' => $scoTracking->getLessonMode(), 421 | 'is_locked' => $scoTracking->getIsLocked(), 422 | 'details' => $scoTracking->getDetails(), 423 | 'latest_date' => $scoTracking->getLatestDate(), 424 | 'created_at' => Carbon::now(), 425 | 'updated_at' => Carbon::now(), 426 | ]); 427 | 428 | $scoTracking->setUuid($storeTracking->uuid); 429 | $scoTracking->setProgression($storeTracking->progression); 430 | $scoTracking->setScoreRaw($storeTracking->score_raw); 431 | $scoTracking->setScoreMin($storeTracking->score_min); 432 | $scoTracking->setScoreMax($storeTracking->score_max); 433 | $scoTracking->setScoreScaled($storeTracking->score_scaled); 434 | $scoTracking->setLessonStatus($storeTracking->lesson_status); 435 | $scoTracking->setCompletionStatus($storeTracking->completion_status); 436 | $scoTracking->setSessionTime($storeTracking->session_time); 437 | $scoTracking->setTotalTimeInt($storeTracking->total_time_int); 438 | $scoTracking->setTotalTimeString($storeTracking->total_time_string); 439 | $scoTracking->setEntry($storeTracking->entry); 440 | $scoTracking->setSuspendData($storeTracking->suspend_data); 441 | $scoTracking->setCredit($storeTracking->credit); 442 | $scoTracking->setExitMode($storeTracking->exit_mode); 443 | $scoTracking->setLessonLocation($storeTracking->lesson_location); 444 | $scoTracking->setLessonMode($storeTracking->lesson_mode); 445 | $scoTracking->setIsLocked($storeTracking->is_locked); 446 | $scoTracking->setDetails($storeTracking->details); 447 | $scoTracking->setLatestDate(Carbon::parse($storeTracking->latest_date)); 448 | 449 | return $scoTracking; 450 | } 451 | 452 | public function findScoTrackingId($scoUuid, $scoTrackingUuid) 453 | { 454 | return ScormScoTrackingModel::with([ 455 | 'sco' 456 | ])->whereHas('sco', function (Builder $query) use ($scoUuid) { 457 | $query->where('uuid', $scoUuid); 458 | })->where('uuid', $scoTrackingUuid) 459 | ->firstOrFail(); 460 | } 461 | 462 | public function checkUserIsCompletedScorm($scormId, $userId) 463 | { 464 | 465 | $completedSco = []; 466 | $scos = ScormScoModel::where('scorm_id', $scormId)->get(); 467 | 468 | foreach ($scos as $sco) { 469 | $scoTracking = ScormScoTrackingModel::where('sco_id', $sco->id)->where('user_id', $userId)->first(); 470 | 471 | if ($scoTracking && ($scoTracking->lesson_status == 'passed' || $scoTracking->lesson_status == 'completed')) { 472 | $completedSco[] = true; 473 | } 474 | } 475 | 476 | if (count($completedSco) == $scos->count()) { 477 | return true; 478 | } else { 479 | return false; 480 | } 481 | } 482 | 483 | public function updateScoTracking($scoUuid, $userId, $data) 484 | { 485 | $tracking = $this->createScoTracking($scoUuid, $userId); 486 | $tracking->setLatestDate(Carbon::now()); 487 | $sco = $tracking->getSco(); 488 | $scorm = ScormModel::where('id', $sco['scorm_id'])->firstOrFail(); 489 | 490 | $statusPriority = [ 491 | 'unknown' => 0, 492 | 'not attempted' => 1, 493 | 'browsed' => 2, 494 | 'incomplete' => 3, 495 | 'completed' => 4, 496 | 'failed' => 5, 497 | 'passed' => 6, 498 | ]; 499 | 500 | switch ($scorm->version) { 501 | case Scorm::SCORM_12: 502 | if (isset($data['cmi.suspend_data']) && !empty($data['cmi.suspend_data'])) { 503 | $tracking->setSuspendData($data['cmi.suspend_data']); 504 | } 505 | 506 | $scoreRaw = isset($data['cmi.core.score.raw']) ? intval($data['cmi.core.score.raw']) : null; 507 | $scoreMin = isset($data['cmi.core.score.min']) ? intval($data['cmi.core.score.min']) : null; 508 | $scoreMax = isset($data['cmi.core.score.max']) ? intval($data['cmi.core.score.max']) : null; 509 | $lessonStatus = isset($data['cmi.core.lesson_status']) ? $data['cmi.core.lesson_status'] : 'unknown'; 510 | $sessionTime = isset($data['cmi.core.session_time']) ? $data['cmi.core.session_time'] : null; 511 | $sessionTimeInHundredth = $this->convertTimeInHundredth($sessionTime); 512 | $progression = !empty($scoreRaw) ? floatval($scoreRaw) : 0; 513 | $entry = isset($data['cmi.core.entry']) ? $data['cmi.core.entry'] : null; 514 | $exit = isset($data['cmi.core.exit']) ? $data['cmi.core.exit'] : null; 515 | $lessonLocation = isset($data['cmi.core.lesson_location']) ? $data['cmi.core.lesson_location'] : null; 516 | $totalTime = isset($data['cmi.core.total_time']) ? $data['cmi.core.total_time'] : 0; 517 | 518 | $tracking->setDetails($data); 519 | $tracking->setEntry($entry); 520 | $tracking->setExitMode($exit); 521 | $tracking->setLessonLocation($lessonLocation); 522 | $tracking->setSessionTime($sessionTimeInHundredth); 523 | 524 | // Compute total time 525 | $totalTimeInHundredth = $this->convertTimeInHundredth($totalTime); 526 | $tracking->setTotalTime($totalTimeInHundredth, Scorm::SCORM_12); 527 | 528 | $bestScore = $tracking->getScoreRaw(); 529 | 530 | // Update best score if the current score is better than the previous best score 531 | 532 | if (empty($bestScore) || (!is_null($scoreRaw) && (int)$scoreRaw > (int)$bestScore)) { 533 | $tracking->setScoreRaw($scoreRaw); 534 | $tracking->setScoreMin($scoreMin); 535 | $tracking->setScoreMax($scoreMax); 536 | } 537 | 538 | $tracking->setLessonStatus($lessonStatus); 539 | $bestStatus = $lessonStatus; 540 | 541 | if (empty($progression) && ('completed' === $bestStatus || 'passed' === $bestStatus)) { 542 | $progression = 100; 543 | } 544 | 545 | if ($progression > $tracking->getProgression()) { 546 | $tracking->setProgression($progression); 547 | } 548 | 549 | break; 550 | 551 | case Scorm::SCORM_2004: 552 | $tracking->setDetails($data); 553 | 554 | if (isset($data['cmi.suspend_data']) && !empty($data['cmi.suspend_data'])) { 555 | $tracking->setSuspendData($data['cmi.suspend_data']); 556 | } 557 | 558 | $dataSessionTime = isset($data['cmi.session_time']) ? 559 | $this->formatSessionTime($data['cmi.session_time']) : 560 | 'PT0S'; 561 | $completionStatus = isset($data['cmi.completion_status']) ? $data['cmi.completion_status'] : 'unknown'; 562 | $successStatus = isset($data['cmi.success_status']) ? $data['cmi.success_status'] : 'unknown'; 563 | $scoreRaw = isset($data['cmi.score.raw']) ? intval($data['cmi.score.raw']) : null; 564 | $scoreMin = isset($data['cmi.score.min']) ? intval($data['cmi.score.min']) : null; 565 | $scoreMax = isset($data['cmi.score.max']) ? intval($data['cmi.score.max']) : null; 566 | $scoreScaled = isset($data['cmi.score.scaled']) ? floatval($data['cmi.score.scaled']) : null; 567 | $progression = isset($data['cmi.progress_measure']) ? floatval($data['cmi.progress_measure']) : 0; 568 | $bestScore = $tracking->getScoreRaw(); 569 | 570 | // Computes total time 571 | $totalTime = new \DateInterval($tracking->getTotalTimeString()); 572 | 573 | try { 574 | $sessionTime = new \DateInterval($dataSessionTime); 575 | } catch (\Exception $e) { 576 | $sessionTime = new \DateInterval('PT0S'); 577 | } 578 | $computedTime = new \DateTime(); 579 | $computedTime->setTimestamp(0); 580 | $computedTime->add($totalTime); 581 | $computedTime->add($sessionTime); 582 | $computedTimeInSecond = $computedTime->getTimestamp(); 583 | $totalTimeInterval = $this->retrieveIntervalFromSeconds($computedTimeInSecond); 584 | $data['cmi.total_time'] = $totalTimeInterval; 585 | $tracking->setTotalTimeString($totalTimeInterval); 586 | 587 | // Update best score if the current score is better than the previous best score 588 | if (empty($bestScore) || (!is_null($scoreRaw) && (int)$scoreRaw > (int)$bestScore)) { 589 | $tracking->setScoreRaw($scoreRaw); 590 | $tracking->setScoreMin($scoreMin); 591 | $tracking->setScoreMax($scoreMax); 592 | $tracking->setScoreScaled($scoreScaled); 593 | } 594 | 595 | // Update best success status and completion status 596 | $lessonStatus = $completionStatus; 597 | if (in_array($successStatus, ['passed', 'failed'])) { 598 | $lessonStatus = $successStatus; 599 | } 600 | 601 | $tracking->setLessonStatus($lessonStatus); 602 | $bestStatus = $lessonStatus; 603 | 604 | if ( 605 | empty($tracking->getCompletionStatus()) 606 | || ($completionStatus !== $tracking->getCompletionStatus() && $statusPriority[$completionStatus] > $statusPriority[$tracking->getCompletionStatus()]) 607 | ) { 608 | // This is no longer needed as completionStatus and successStatus are merged together 609 | // I keep it for now for possible retro compatibility 610 | $tracking->setCompletionStatus($completionStatus); 611 | } 612 | 613 | if (empty($progression) && ('completed' === $bestStatus || 'passed' === $bestStatus)) { 614 | $progression = 100; 615 | } 616 | 617 | if ($progression > $tracking->getProgression()) { 618 | $tracking->setProgression($progression); 619 | } 620 | 621 | break; 622 | } 623 | 624 | $updateResult = ScormScoTrackingModel::where('user_id', $tracking->getUserId()) 625 | ->where('sco_id', $sco['id']) 626 | ->firstOrFail(); 627 | 628 | $updateResult->progression = $tracking->getProgression(); 629 | $updateResult->score_raw = $tracking->getScoreRaw(); 630 | $updateResult->score_min = $tracking->getScoreMin(); 631 | $updateResult->score_max = $tracking->getScoreMax(); 632 | $updateResult->score_scaled = $tracking->getScoreScaled(); 633 | $updateResult->lesson_status = $tracking->getLessonStatus(); 634 | $updateResult->completion_status = $tracking->getCompletionStatus(); 635 | $updateResult->session_time = $tracking->getSessionTime(); 636 | $updateResult->total_time_int = $tracking->getTotalTimeInt(); 637 | $updateResult->total_time_string = $tracking->getTotalTimeString(); 638 | $updateResult->entry = $tracking->getEntry(); 639 | $updateResult->suspend_data = $tracking->getSuspendData(); 640 | $updateResult->exit_mode = $tracking->getExitMode(); 641 | $updateResult->credit = $tracking->getCredit(); 642 | $updateResult->lesson_location = $tracking->getLessonLocation(); 643 | $updateResult->lesson_mode = $tracking->getLessonMode(); 644 | $updateResult->is_locked = $tracking->getIsLocked(); 645 | $updateResult->details = $tracking->getDetails(); 646 | $updateResult->latest_date = $tracking->getLatestDate(); 647 | 648 | $updateResult->save(); 649 | 650 | return $updateResult; 651 | } 652 | 653 | public function resetUserData($scormId, $userId) 654 | { 655 | $scos = ScormScoModel::where('scorm_id', $scormId)->get(); 656 | 657 | foreach ($scos as $sco) { 658 | $scoTracking = ScormScoTrackingModel::where('sco_id', $sco->id)->where('user_id', $userId)->delete(); 659 | } 660 | } 661 | 662 | private function convertTimeInHundredth($time) 663 | { 664 | if ($time != null) { 665 | $timeInArray = explode(':', $time); 666 | $timeInArraySec = explode('.', $timeInArray[2]); 667 | $timeInHundredth = 0; 668 | 669 | if (isset($timeInArraySec[1])) { 670 | if (1 === strlen($timeInArraySec[1])) { 671 | $timeInArraySec[1] .= '0'; 672 | } 673 | $timeInHundredth = intval($timeInArraySec[1]); 674 | } 675 | $timeInHundredth += intval($timeInArraySec[0]) * 100; 676 | $timeInHundredth += intval($timeInArray[1]) * 6000; 677 | $timeInHundredth += intval($timeInArray[0]) * 360000; 678 | 679 | return $timeInHundredth; 680 | } else { 681 | return 0; 682 | } 683 | } 684 | 685 | /** 686 | * Converts a time in seconds to a DateInterval string. 687 | * 688 | * @param int $seconds 689 | * 690 | * @return string 691 | */ 692 | private function retrieveIntervalFromSeconds($seconds) 693 | { 694 | $result = ''; 695 | $remainingTime = (int) $seconds; 696 | 697 | if (empty($remainingTime)) { 698 | $result .= 'PT0S'; 699 | } else { 700 | $nbDays = (int) ($remainingTime / 86400); 701 | $remainingTime %= 86400; 702 | $nbHours = (int) ($remainingTime / 3600); 703 | $remainingTime %= 3600; 704 | $nbMinutes = (int) ($remainingTime / 60); 705 | $nbSeconds = $remainingTime % 60; 706 | $result .= 'P' . $nbDays . 'DT' . $nbHours . 'H' . $nbMinutes . 'M' . $nbSeconds . 'S'; 707 | } 708 | 709 | return $result; 710 | } 711 | 712 | private function formatSessionTime($sessionTime) 713 | { 714 | $formattedValue = 'PT0S'; 715 | $generalPattern = '/^P([0-9]+Y)?([0-9]+M)?([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+S)?$/'; 716 | $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$/'; 717 | 718 | if ('PT' !== $sessionTime) { 719 | if (preg_match($generalPattern, $sessionTime)) { 720 | $formattedValue = $sessionTime; 721 | } elseif (preg_match($decimalPattern, $sessionTime)) { 722 | $formattedValue = preg_replace(['/\.[0-9]+S$/'], ['S'], $sessionTime); 723 | } 724 | } 725 | 726 | return $formattedValue; 727 | } 728 | 729 | /** 730 | * Clean resources and throw exception. 731 | */ 732 | private function onError($msg) 733 | { 734 | $this->scormDisk->deleteScorm($this->uuid); 735 | throw new InvalidScormArchiveException($msg); 736 | } 737 | } 738 | --------------------------------------------------------------------------------