Passed
Pull Request — master (#6921)
by
unknown
09:03
created

Cc1p3Convert::buildInstancesForV13()   C

Complexity

Conditions 16
Paths 88

Size

Total Lines 78
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 16
eloc 46
c 1
b 0
f 0
nc 88
nop 1
dl 0
loc 78
rs 5.5666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import;
6
7
use Chamilo\CoreBundle\Framework\Container;
8
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Base\CcBase;
9
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Converter\Cc13Forum;
10
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Converter\Cc13Quiz;
11
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Converter\Cc13Resource;
12
use DocumentManager;
13
use DOMElement;
14
use DOMXPath;
15
use FilesystemIterator;
16
use RecursiveDirectoryIterator;
17
use RecursiveIteratorIterator;
18
19
use const DIRECTORY_SEPARATOR;
20
21
class Cc1p3Convert extends CcBase
22
{
23
    // Keep local CC_TYPE_* for readability; values must match the manifest exactly (v1p3).
24
    public const CC_TYPE_FORUM = 'imsdt_xmlv1p3';
25
    public const CC_TYPE_QUIZ = 'imsqti_xmlv1p3/imscc_xmlv1p3/assessment';
26
    public const CC_TYPE_QUESTION_BANK = 'imsqti_xmlv1p3/imscc_xmlv1p3/question-bank';
27
    public const CC_TYPE_WEBLINK = 'imswl_xmlv1p3';
28
    public const CC_TYPE_ASSOCIATED_CONTENT = 'associatedcontent/imscc_xmlv1p3/learning-application-resource';
29
    public const CC_TYPE_WEBCONTENT = 'webcontent';
30
    public const CC_TYPE_BASICLTI = 'imsbasiclti_xmlv1p3';
31
32
    /**
33
     * XPath namespaces for imsmanifest.xml (v1p3 or plain imscp v1p1).
34
     */
35
    public static $namespaces = [
36
        'imscc' => 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1',
37
        'lomimscc' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest',
38
        'lom' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource',
39
        'voc' => 'http://ltsc.ieee.org/xsd/LOM/vocab',
40
        'xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
41
        'cc' => 'http://www.imsglobal.org/xsd/imsccv1p3/imsccauth_v1p1',
42
    ];
43
44
    // These remain for backward compatibility; converters may read them.
45
    public static $restypes = ['associatedcontent/imscc_xmlv1p3/learning-application-resource', 'webcontent'];
46
    public static $forumns = ['dt' => 'http://www.imsglobal.org/xsd/imsccv1p3/imsdt_v1p3'];
47
    public static $quizns = ['xmlns' => 'http://www.imsglobal.org/xsd/ims_qtiasiv1p2'];
48
    public static $resourcens = ['wl' => 'http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3'];
49
    public static array $basicltins = [
50
        'xmlns' => 'http://www.imsglobal.org/xsd/imslticc_v1p0',
51
        'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0',
52
        'lticm' => 'http://www.imsglobal.org/xsd/imslticm_v1p0',
53
        'lticp' => 'http://www.imsglobal.org/xsd/imslticp_v1p0',
54
    ];
55
56
    public function __construct(string $path_to_manifest)
57
    {
58
        parent::__construct($path_to_manifest);
59
        // Point our "imscc" prefix to the actual root namespace (v1p3 or plain imscp v1p1).
60
        $this->normalizeImscpNamespace();
61
    }
62
63
    /**
64
     * Resolve the absolute path to imsmanifest.xml in an extracted cartridge folder.
65
     * It searches a few common locations and returns the first existing path.
66
     */
67
    public static function getManifest(string $extractedDir): ?string
68
    {
69
        if (is_file($extractedDir) && preg_match('~imsmanifest\.xml$~i', $extractedDir)) {
70
            return $extractedDir;
71
        }
72
73
        $candidates = [
74
            rtrim($extractedDir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'imsmanifest.xml',
75
        ];
76
77
        foreach ($candidates as $c) {
78
            if (is_file($c)) {
79
                return $c;
80
            }
81
        }
82
83
        // Shallow recursive search (depth 2) just in case
84
        $it = new RecursiveIteratorIterator(
85
            new RecursiveDirectoryIterator($extractedDir, FilesystemIterator::SKIP_DOTS),
86
            RecursiveIteratorIterator::SELF_FIRST
87
        );
88
        $maxDepth = 2;
89
        foreach ($it as $fs) {
90
            if ($it->getDepth() > $maxDepth) {
91
                continue;
92
            }
93
            if ($fs->isFile() && 0 === strcasecmp($fs->getFilename(), 'imsmanifest.xml')) {
94
                return $fs->getPathname();
95
            }
96
        }
97
98
        return null;
99
    }
100
101
    /**
102
     * Scan the manifest and create Chamilo resources.
103
     */
104
    public function generateImportData(): void
105
    {
106
        $xpath = static::newxPath(static::$manifest, static::$namespaces);
107
108
        // If parent logic didn't populate instances, build them here (v1.3-aware).
109
        if (empty(self::$instances['instances']) || !\is_array(self::$instances['instances'])) {
110
            $this->buildInstancesForV13($xpath);
111
        }
112
113
        // Converters
114
        $resourcesConv = new Cc13Resource();
115
        $forumsConv = new Cc13Forum();
116
        $quizConv = new Cc13Quiz();
117
118
        // Build data payloads from embedded XML resources
119
        $documentValues = $resourcesConv->generateData('document');
120
        $linkValues = $resourcesConv->generateData('link');
121
        $forumValues = $forumsConv->generateData();
122
        $quizValues = $quizConv->generateData();
123
124
        // Ensure /document/commoncartridge exists (Chamilo 2 resource tree)
125
        if (!empty($forumValues) || !empty($quizValues) || !empty($documentValues) || !empty($linkValues)) {
126
            $courseInfo = api_get_course_info();
127
            $courseEntity = api_get_course_entity($courseInfo['real_id']);
128
            $sessionEnt = api_get_session_entity((int) api_get_session_id());
129
            $groupEnt = api_get_group_entity(0);
130
131
            $docRepo = Container::getDocumentRepository();
132
133
            $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $sessionEnt, $groupEnt): int {
134
                $rel = '/'.ltrim($relPath, '/');
135
                if ('/' === $rel || '' === $rel) {
136
                    return 0;
137
                }
138
139
                $parts = array_values(array_filter(explode('/', trim($rel, '/'))));
140
                $accum = '';
141
                $parentId = 0;
142
143
                foreach ($parts as $seg) {
144
                    $accum .= '/'.$seg;
145
146
                    $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
147
148
                    $existing = $docRepo->findCourseResourceByTitle(
149
                        $seg,
150
                        $parentRes->getResourceNode(),
0 ignored issues
show
Bug introduced by
The method getResourceNode() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

150
                        $parentRes->/** @scrutinizer ignore-call */ 
151
                                    getResourceNode(),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
151
                        $courseEntity,
152
                        $sessionEnt,
153
                        $groupEnt
154
                    );
155
156
                    if ($existing && method_exists($existing, 'getIid')) {
157
                        $parentId = (int) $existing->getIid();
158
159
                        continue;
160
                    }
161
162
                    $entity = DocumentManager::addDocument(
163
                        ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']],
164
                        $accum,
165
                        'folder',
166
                        0,
167
                        $seg,
168
                        null,
169
                        0,
170
                        null,
171
                        0,
172
                        (int) ($sessionEnt ? $sessionEnt->getId() : 0),
173
                        0,
174
                        false,
175
                        '',
176
                        $parentId,
177
                        ''
178
                    );
179
180
                    $parentId = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
181
                }
182
183
                return $parentId;
184
            };
185
186
            // Create the base folder once
187
            $ensureFolder('/commoncartridge');
188
        }
189
190
        self::logAction(
191
            'cc13: payload sizes',
192
            [
193
                'docs' => \count($documentValues ?? []),
194
                'links' => \count($linkValues ?? []),
195
                'forums' => \count($forumValues ?? []),
196
                'quizzes' => \count($quizValues ?? []),
197
            ]
198
        );
199
200
        // Persist resources
201
        if (!empty($forumValues)) {
202
            $forumsConv->storeForums($forumValues);
203
        }
204
        if (!empty($quizValues)) {
205
            $quizConv->storeQuizzes($quizValues);
206
        }
207
        if (!empty($documentValues)) {
208
            $resourcesConv->storeDocuments($documentValues, static::$pathToManifestFolder);
209
        }
210
        if (!empty($linkValues)) {
211
            $resourcesConv->storeLinks($linkValues);
212
        }
213
    }
214
215
    /**
216
     * CC 1.3-specific instance builder that tolerates:
217
     *  - root NS = imscp_v1p1 (plain IMS CP)
218
     *  - resource@var variants v1p1 and v1p3 (imswl/imsdt)
219
     *  - missing @href (uses first <file href="...">)
220
     *  - heuristic by path for links/discussions
221
     */
222
    private function buildInstancesForV13(DOMXPath $xp): void
223
    {
224
        // Initialize bag once.
225
        if (empty(self::$instances['instances']) || !\is_array(self::$instances['instances'])) {
226
            self::$instances['instances'] = [];
227
        }
228
229
        // 1) Map resource id => basic info (type, href, first file)
230
        $resMap = $this->collectResourcesMap($xp);
231
232
        // 2) Map resource id <= referenced from organizations with a title
233
        $itemTitles = $this->collectItemTitles($xp);
234
235
        $nextInstance = 0;
236
        $push = function (string $bucket, array $payload) use (&$nextInstance): void {
237
            $nextInstance++;
238
            $payload['instance'] = $nextInstance;
239
            self::$instances['instances'][$bucket] ??= [];
240
            self::$instances['instances'][$bucket][] = $payload;
241
        };
242
243
        // 3) Create instances for every item that points to a resource
244
        foreach ($itemTitles as $resId => $titles) {
245
            if (!isset($resMap[$resId])) {
246
                continue;
247
            }
248
            $ri = $resMap[$resId];
249
250
            foreach ($titles as $title) {
251
                $bucket = $this->bucketForType($ri['type']);
252
253
                // Heuristic fallback by src path when type wasn't matched explicitly
254
                if (null === $bucket && '' !== $ri['src']) {
255
                    $path = strtolower($ri['src']);
256
                    if (preg_match('~(^|/)weblinks/[^/]+\.xml$~i', $path)) {
257
                        $bucket = 'link';
258
                    } elseif (preg_match('~(^|/)discussions/[^/]+\.xml$~i', $path)) {
259
                        $bucket = 'forum';
260
                    }
261
                }
262
263
                if (null === $bucket) {
264
                    continue;
265
                }
266
267
                $payload = [
268
                    'title' => $title,
269
                    'resource_identifier' => $resId,
270
                    'src' => $ri['src'],
271
                    'common_cartridge_type' => $ri['type'],
272
                ];
273
                $push($bucket, $payload);
274
            }
275
        }
276
277
        // 4) Detached resources (webcontent/associatedcontent with no item)
278
        foreach ($resMap as $resId => $ri) {
279
            $bucket = $this->bucketForType($ri['type']);
280
281
            if ('document' === $bucket && empty($itemTitles[$resId])) {
282
                $title = '' !== $ri['src'] ? basename($ri['src']) : ($ri['href'] ?: $resId);
283
                $payload = [
284
                    'title' => $title,
285
                    'resource_identifier' => $resId,
286
                    'src' => $ri['src'],
287
                    'common_cartridge_type' => $ri['type'],
288
                ];
289
                $push('document', $payload);
290
            }
291
        }
292
293
        // Debug log for visibility
294
        self::logAction('buildInstances:v13', [
295
            'doc' => \count(self::$instances['instances']['document'] ?? []),
296
            'link' => \count(self::$instances['instances']['link'] ?? []),
297
            'forum' => \count(self::$instances['instances']['forum'] ?? []),
298
            'quiz' => \count(self::$instances['instances']['quiz'] ?? []),
299
            'bank' => \count(self::$instances['instances']['question_bank'] ?? []),
300
        ]);
301
    }
302
303
    /**
304
     * Build a map of all <resource> elements.
305
     * Returns: id => ['type'=>..., 'href'=>..., 'src'=>firstFileOrHref].
306
     */
307
    private function collectResourcesMap(DOMXPath $xp): array
308
    {
309
        $map = [];
310
311
        // 1) Prefixed query (v1p3/normalized root NS)
312
        $nodes = $xp->query('/imscc:manifest/imscc:resources/imscc:resource');
313
314
        // 2) Fallback without namespaces for plain imscp_v1p1 roots
315
        if (!$nodes || 0 === $nodes->length) {
0 ignored issues
show
introduced by
$nodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
316
            $nodes = $xp->query('/*[local-name()="manifest"]/*[local-name()="resources"]/*[local-name()="resource"]');
317
            self::logAction('collectResourcesMap: fallback local-name() engaged');
318
        }
319
320
        if (!$nodes) {
0 ignored issues
show
introduced by
$nodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
321
            return $map;
322
        }
323
324
        foreach ($nodes as $res) {
325
            /** @var DOMElement $res */
326
            $id = (string) $res->getAttribute('identifier');
327
            $type = (string) $res->getAttribute('type');
328
            $href = (string) $res->getAttribute('href');
329
330
            // First <file href="..."> as safe fallback for src
331
            $fileHref = '';
332
            $file = $xp->query('imscc:file/@href', $res);
333
            if (!$file || 0 === $file->length) {
334
                // Fallback without ns
335
                $file = $xp->query('./*[local-name()="file"]/@href', $res);
336
            }
337
            if ($file && $file->length > 0) {
338
                $fileHref = (string) $file->item(0)->nodeValue;
339
            }
340
341
            $src = '' !== $href ? $href : $fileHref;
342
343
            $map[$id] = [
344
                'type' => $type,
345
                'href' => $href,
346
                'src' => $src,
347
            ];
348
        }
349
350
        self::logAction('collectResourcesMap: counted resources', ['count' => \count($map)]);
351
352
        return $map;
353
    }
354
355
    /**
356
     * Collect item -> resource titles:
357
     * Returns: resourceId => [title1, title2, ...]
358
     */
359
    private function collectItemTitles(DOMXPath $xp): array
360
    {
361
        $byRes = [];
362
363
        // 1) With prefixes
364
        $items = $xp->query('/imscc:manifest/imscc:organizations/imscc:organization//imscc:item[@identifierref]');
365
366
        // 2) Fallback without ns
367
        if (!$items || 0 === $items->length) {
0 ignored issues
show
introduced by
$items is of type DOMNodeList, thus it always evaluated to true.
Loading history...
368
            $items = $xp->query(
369
                '/*[local-name()="manifest"]/*[local-name()="organizations"]/*[local-name()="organization"]'.
370
                '//*[local-name()="item"][@identifierref]'
371
            );
372
            self::logAction('collectItemTitles: fallback local-name() engaged');
373
        }
374
375
        if (!$items) {
0 ignored issues
show
introduced by
$items is of type DOMNodeList, thus it always evaluated to true.
Loading history...
376
            return $byRes;
377
        }
378
379
        foreach ($items as $it) {
380
            /** @var DOMElement $it */
381
            $rid = (string) $it->getAttribute('identifierref');
382
            if ('' === $rid) {
383
                continue;
384
            }
385
386
            // Title node in both modes
387
            $t = $xp->query('imscc:title', $it);
388
            if (!$t || 0 === $t->length) {
389
                $t = $xp->query('./*[local-name()="title"]', $it);
390
            }
391
392
            $title = ($t && $t->length > 0) ? trim((string) $t->item(0)->nodeValue) : '';
393
            if ('' === $title) {
394
                $title = $rid;
395
            }
396
397
            $byRes[$rid] ??= [];
398
            $byRes[$rid][] = $title;
399
        }
400
401
        self::logAction('collectItemTitles: items grouped by resource', ['resources' => \count($byRes)]);
402
403
        return $byRes;
404
    }
405
406
    /**
407
     * Map resource/@var to our instance bucket.
408
     * Tolerant to v1p1 and v1p3 variants for imsdt/imswl; also accepts QTI patterns.
409
     */
410
    private function bucketForType(string $resType): ?string
411
    {
412
        $t = strtolower(trim($resType));
413
414
        // Documents
415
        if (self::CC_TYPE_WEBCONTENT === $t || self::CC_TYPE_ASSOCIATED_CONTENT === $t) {
416
            return 'document';
417
        }
418
        if ('webcontent' === $t) { // be explicit in case constants change
419
            return 'document';
420
        }
421
422
        // WebLink (accept v1p1 and v1p3)
423
        // Examples: imswl_xmlv1p3, imswl_xmlv1p1
424
        if (self::CC_TYPE_WEBLINK === $t || str_contains($t, 'imswl')) {
425
            return 'link';
426
        }
427
428
        // Discussion Topic (accept v1p1 and v1p3)
429
        // Examples: imsdt_xmlv1p3, imsdt_xmlv1p1
430
        if (self::CC_TYPE_FORUM === $t || str_contains($t, 'imsdt')) {
431
            return 'forum';
432
        }
433
434
        // Quizzes / question banks (be defensive with QTI strings)
435
        if (str_contains($t, '/assessment')) {
436
            return 'quiz';
437
        }
438
        if (str_contains($t, 'question-bank')) {
439
            return 'question_bank';
440
        }
441
442
        return null;
443
    }
444
445
    /**
446
     * Some CC manifests use plain IMS CP default NS (imscp_v1p1).
447
     * Our XPath prefix "imscc" must point to the root NS to match elements.
448
     */
449
    private function normalizeImscpNamespace(): void
450
    {
451
        if (!self::$manifest || !method_exists(self::$manifest, 'documentElement')) {
452
            return;
453
        }
454
455
        $root = self::$manifest->documentElement;
456
        if (!$root) {
457
            return;
458
        }
459
460
        $rootNs = (string) $root->namespaceURI;
461
462
        // Accept either of these as valid "manifest" NS for CC 1.3:
463
        //  - http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1  (some toolchains)
464
        //  - http://www.imsglobal.org/xsd/imscp_v1p1            (plain IMS CP)
465
        $allowed = [
466
            'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1',
467
            'http://www.imsglobal.org/xsd/imscp_v1p1',
468
        ];
469
470
        if (\in_array($rootNs, $allowed, true)) {
471
            // Point our "imscc" prefix to whatever the root is actually using.
472
            self::$namespaces['imscc'] = $rootNs;
473
        }
474
    }
475
}
476