├── .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 | [](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 |
--------------------------------------------------------------------------------