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

Cc13Export::collectWebLinks()   C

Complexity

Conditions 14
Paths 18

Size

Total Lines 44
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 14
eloc 23
c 1
b 0
f 0
nc 18
nop 0
dl 0
loc 44
rs 6.2666

How to fix   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
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder;
8
9
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
10
use Chamilo\CoreBundle\Framework\Container;
11
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
12
use Chamilo\CourseBundle\Entity\CDocument;
13
use Chamilo\CourseBundle\Repository\CDocumentRepository;
14
use Database;
15
use Doctrine\ORM\EntityManagerInterface;
16
use DOMDocument;
17
use DOMElement;
18
use FilesystemIterator;
19
use RecursiveDirectoryIterator;
20
use RecursiveIteratorIterator;
21
use ReflectionClass;
22
use RuntimeException;
23
use SplFileInfo;
24
use Symfony\Component\HttpKernel\KernelInterface;
25
use Throwable;
26
use ZipArchive;
27
28
use const DIRECTORY_SEPARATOR;
29
use const JSON_PRETTY_PRINT;
30
use const JSON_UNESCAPED_SLASHES;
31
use const JSON_UNESCAPED_UNICODE;
32
use const PATHINFO_EXTENSION;
33
use const PHP_EOL;
34
35
/**
36
 * Common Cartridge 1.3 exporter for Chamilo 2.
37
 *
38
 * - Inputs: legacy Course bag from CourseBuilder.
39
 * - Exports:
40
 *   * Documents → webcontent files under "resources/...".
41
 *   * Web Links → IMS WebLink (imswl_xmlv1p1) under "weblinks/...".
42
 *   * Discussions → IMS Discussion Topic (imsdt_xmlv1p1) under "discussions/...".
43
 * - Manifest: <resources> + a hierarchical <organization> tree.
44
 */
45
class Cc13Export
46
{
47
    /**
48
     * Legacy course container (DTO with resources[...]).
49
     */
50
    private object $course;
51
52
    private bool $selectionMode;
53
    private bool $debug;
54
55
    /**
56
     * Working directory on disk.
57
     */
58
    private string $workdir = '';
59
60
    /**
61
     * Absolute path to the resulting .imscc file.
62
     */
63
    private string $packagePath = '';
64
65
    /**
66
     * Doctrine & repositories.
67
     */
68
    private EntityManagerInterface $em;
69
    private CDocumentRepository $docRepo;
70
    private ResourceNodeRepository $rnRepo;
71
72
    /**
73
     * Project base dir (for var/upload/resource).
74
     */
75
    private string $projectDir = '';
76
77
    /**
78
     * Cached CourseEntity for the current course code.
79
     */
80
    private ?CourseEntity $courseEntity = null;
81
82
    /**
83
     * Course code kept for legacy FS fallback.
84
     */
85
    private string $courseCodeForLegacy = '';
86
87
    /**
88
     * Path to debug log file (in system temp dir).
89
     */
90
    private string $logFile = '';
91
92
    /**
93
     * CC 1.3 namespaces.
94
     */
95
    private array $ns = [
96
        'imscc' => 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1',
97
        'lomimscc' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest',
98
        'lom' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource',
99
        'xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
100
    ];
101
102
    /**
103
     * schemaLocation map (optional).
104
     */
105
    private array $schemaLocations = [
106
        'imscc' => 'http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd',
107
        'lomimscc' => 'http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd',
108
        'lom' => 'http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd',
109
    ];
110
111
    public function __construct(object $course, bool $selectionMode = false, bool $debug = false)
112
    {
113
        $this->course = $course;
114
        $this->selectionMode = $selectionMode;
115
        $this->debug = $debug;
116
117
        // Resolve services safely (throw if missing)
118
        $this->em = Database::getManager();
119
120
        $docRepo = Container::getDocumentRepository();
121
        if (!$docRepo instanceof CDocumentRepository) {
0 ignored issues
show
introduced by
$docRepo is always a sub-type of Chamilo\CourseBundle\Rep...ory\CDocumentRepository.
Loading history...
122
            throw new RuntimeException('CDocumentRepository service not available');
123
        }
124
        $this->docRepo = $docRepo;
125
126
        $rnRepoRaw = Container::$container->get(ResourceNodeRepository::class);
0 ignored issues
show
Bug introduced by
The method get() 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

126
        /** @scrutinizer ignore-call */ 
127
        $rnRepoRaw = Container::$container->get(ResourceNodeRepository::class);

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...
127
        if (!$rnRepoRaw instanceof ResourceNodeRepository) {
128
            throw new RuntimeException('ResourceNodeRepository service not available');
129
        }
130
        $this->rnRepo = $rnRepoRaw;
131
132
        $kernel = Container::$container->get('kernel');
133
        if (!$kernel instanceof KernelInterface) {
134
            throw new RuntimeException('Kernel service not available');
135
        }
136
        $this->projectDir = rtrim($kernel->getProjectDir(), '/');
137
138
        // Prepare log file in system temp dir
139
        $this->logFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'cc13_export.log';
140
        if ($this->debug) {
141
            $this->log('Exporter constructed', [
142
                'class_file' => (new ReflectionClass(self::class))->getFileName(),
143
                'projectDir' => $this->projectDir,
144
                'tempDir' => sys_get_temp_dir(),
145
            ]);
146
        }
147
    }
148
149
    /**
150
     * Build the .imscc package and return its absolute path.
151
     *
152
     * @param string      $courseCode current course code (api_get_course_id())
153
     * @param string|null $exportDir  optional subdir name; defaults to "cc13_YYYYmmdd_His"
154
     */
155
    public function export(string $courseCode, ?string $exportDir = null): string
156
    {
157
        $this->log('Export start', ['courseCode' => $courseCode]);
158
159
        $this->courseEntity = $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode]);
160
        if (!$this->courseEntity instanceof CourseEntity) {
161
            $this->log('Course not found', ['courseCode' => $courseCode], 'error');
162
163
            throw new RuntimeException('Course not found for code: '.$courseCode);
164
        }
165
166
        $this->courseCodeForLegacy = (string) $this->courseEntity->getCode();
167
168
        $exportDir = $exportDir ?: ('cc13_'.date('Ymd_His'));
169
        $baseTmp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
170
        $this->workdir = $baseTmp.DIRECTORY_SEPARATOR.$exportDir;
171
172
        $this->log('Workdir prepare', ['workdir' => $this->workdir]);
173
174
        // Prepare working dir
175
        $this->rrmdir($this->workdir);
176
        $this->mkdirp($this->workdir.'/resources');
177
        $this->mkdirp($this->workdir.'/_generated'); // for inline-content exports
178
179
        // Environment checks
180
        $this->checkEnvironment();
181
182
        // Collect candidate resources
183
        $docList = $this->collectDocuments();
184
        $this->mkdirp($this->workdir.'/weblinks');
185
        $wlList = $this->collectWebLinks();
186
        $this->mkdirp($this->workdir.'/discussions');
187
        $dtList = $this->collectDiscussionTopics();
188
189
        $this->log('Collected resources', [
190
            'documents' => \count($docList),
191
            'weblinks' => \count($wlList),
192
            'discussions' => \count($dtList),
193
        ]);
194
195
        // Materialize into package and build resource entries
196
        $resourceEntries = [];
197
        $resourceEntries = array_merge(
198
            $resourceEntries,
199
            $this->copyDocumentsIntoPackage($docList)
200
        );
201
        $resourceEntries = array_merge(
202
            $resourceEntries,
203
            $this->writeWebLinksIntoPackage($wlList)
204
        );
205
        $resourceEntries = array_merge(
206
            $resourceEntries,
207
            $this->writeDiscussionTopicsIntoPackage($dtList)
208
        );
209
210
        // CHANGED: allow export as long as there is at least ONE resource of any supported type.
211
        if (empty($resourceEntries)) {
212
            $this->log('Nothing to export (no CC-compatible resources)', [], 'warn');
213
214
            throw new RuntimeException('Nothing to export (no CC 1.3-compatible resources: documents, links or discussions).');
215
        }
216
217
        // Write imsmanifest.xml
218
        $this->writeManifest($resourceEntries);
219
        $this->log('imsmanifest.xml written', ['resourceCount' => \count($resourceEntries)]);
220
221
        // Zip → .imscc
222
        $filename = \sprintf('%s_cc13_%s.imscc', $this->courseCodeForLegacy, date('Ymd_His'));
223
        $this->packagePath = $this->normalizePath($this->workdir.'/../'.$filename);
224
225
        $this->zipDir($this->workdir, $this->packagePath);
226
        $this->log('Package zipped', ['packagePath' => $this->packagePath]);
227
228
        // Cleanup temp working dir
229
        $this->rrmdir($this->workdir);
230
        $this->log('Workdir cleaned up', ['workdir' => $this->workdir]);
231
232
        $this->log('Export done', ['packagePath' => $this->packagePath]);
233
234
        return $this->packagePath;
235
    }
236
237
    /**
238
     * Write imsmanifest.xml:
239
     * - <manifest> root with minimal LOM metadata
240
     * - <organizations> containing a hierarchical <organization>
241
     * - <resources> with entries for webcontent, weblink, and discussion topic
242
     *
243
     * @param array<int,array{identifier:string, href:string, type:string, title?:string, path?:string}> $resources
244
     */
245
    private function writeManifest(array $resources): void
246
    {
247
        $doc = new DOMDocument('1.0', 'UTF-8');
248
        $doc->formatOutput = true;
249
250
        $imscc = $this->ns['imscc'];
251
252
        // Root <manifest>
253
        $manifest = $doc->createElementNS($imscc, 'manifest');
254
        $manifest->setAttribute('identifier', 'MANIFEST-'.bin2hex(random_bytes(6)));
255
        // Helpful schema attributes (harmless if ignored)
256
        $manifest->setAttribute('schema', 'IMS Common Cartridge');
257
        $manifest->setAttribute('schemaversion', '1.3.0');
258
259
        // Namespace declarations
260
        foreach ($this->ns as $prefix => $uri) {
261
            $manifest->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:'.$prefix, $uri);
262
        }
263
264
        // xsi:schemaLocation (optional)
265
        $schemaLocation = '';
266
        foreach ($this->schemaLocations as $k => $loc) {
267
            if (!isset($this->ns[$k])) {
268
                continue;
269
            }
270
            $schemaLocation .= ($schemaLocation ? ' ' : '').$this->ns[$k].' '.$loc;
271
        }
272
        if ($schemaLocation) {
273
            $manifest->setAttributeNS($this->ns['xsi'], 'xsi:schemaLocation', $schemaLocation);
274
        }
275
276
        // Optional LOM metadata (very small payload)
277
        $metadata = $doc->createElementNS($imscc, 'metadata');
278
        $lom = $doc->createElementNS($this->ns['lomimscc'], 'lom');
279
        $general = $doc->createElementNS($this->ns['lomimscc'], 'general');
280
        $title = $doc->createElementNS($this->ns['lomimscc'], 'title');
281
        $titleStr = $doc->createElementNS($this->ns['lomimscc'], 'string');
282
        $courseTitle = '';
283
284
        try {
285
            if ($this->courseEntity instanceof CourseEntity) {
286
                if (method_exists($this->courseEntity, 'getTitle')) {
287
                    $courseTitle = (string) $this->courseEntity->getTitle();
288
                }
289
                if ('' === $courseTitle && method_exists($this->courseEntity, 'getName')) {
290
                    $courseTitle = (string) $this->courseEntity->getName();
0 ignored issues
show
Bug introduced by
The method getName() does not exist on Chamilo\CoreBundle\Entity\Course. ( Ignorable by Annotation )

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

290
                    $courseTitle = (string) $this->courseEntity->/** @scrutinizer ignore-call */ getName();

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...
291
                }
292
            }
293
        } catch (Throwable) {
294
            // swallow
295
        }
296
        if ('' === $courseTitle) {
297
            $courseTitle = (string) ($this->courseCodeForLegacy ?: 'Course package');
298
        }
299
        $titleStr->nodeValue = $courseTitle;
300
        $title->appendChild($titleStr);
301
        $general->appendChild($title);
302
303
        $lang = $doc->createElementNS($this->ns['lomimscc'], 'language');
304
        $lang->nodeValue = 'en';
305
        $general->appendChild($lang);
306
307
        $lom->appendChild($general);
308
        $metadata->appendChild($lom);
309
        $manifest->appendChild($metadata);
310
311
        // <organizations> (single org with hierarchical items)
312
        $orgs = $doc->createElementNS($imscc, 'organizations');
313
        $orgId = 'ORG-'.bin2hex(random_bytes(4));
314
        $orgs->setAttribute('default', $orgId);
315
        $org = $doc->createElementNS($imscc, 'organization');
316
        $org->setAttribute('identifier', $orgId);
317
        $org->setAttribute('structure', 'hierarchical');
318
319
        // Build <item> tree based on 'path' (preferred) or href-derived path
320
        $folderNodeByPath = [];
321
        $getOrCreateFolder = function (array $parts) use ($doc, $imscc, $org, &$folderNodeByPath): DOMElement {
322
            $acc = '';
323
            $parent = $org;
324
            foreach ($parts as $seg) {
325
                if ('' === $seg) {
326
                    continue;
327
                }
328
                $acc = ('' === $acc) ? $seg : ($acc.'/'.$seg);
329
                if (!isset($folderNodeByPath[$acc])) {
330
                    $item = $doc->createElementNS($imscc, 'item');
331
                    $item->setAttribute('identifier', 'ITEM-'.substr(sha1($acc), 0, 12));
332
                    $titleEl = $doc->createElementNS($imscc, 'title');
333
                    $titleEl->nodeValue = $seg;
334
                    $item->appendChild($titleEl);
335
                    $parent->appendChild($item);
336
                    $folderNodeByPath[$acc] = $item;
337
                }
338
                $parent = $folderNodeByPath[$acc];
339
            }
340
341
            return $parent;
342
        };
343
344
        foreach ($resources as $r) {
345
            $href = (string) ($r['href'] ?? '');
346
            if ('' === $href) {
347
                continue;
348
            }
349
350
            // Prefer explicit 'path' (e.g., "Web Links/Title.xml" or "Discussions/Title.xml")
351
            if (!empty($r['path'])) {
352
                $relForTree = trim((string) $r['path'], '/');
353
            } else {
354
                $relForTree = preg_replace('#^resources/#', '', $href) ?? $href;
355
                $relForTree = ltrim($relForTree, '/');
356
            }
357
358
            $parts = array_values(array_filter(explode('/', $relForTree), static fn ($s) => '' !== $s));
359
            if (empty($parts)) {
360
                continue;
361
            }
362
363
            $fileName = array_pop($parts);
364
            $folderParent = $getOrCreateFolder($parts);
365
366
            $item = $doc->createElementNS($imscc, 'item');
367
            $item->setAttribute('identifier', 'ITEM-'.substr(sha1($href.($r['identifier'] ?? '')), 0, 12));
368
            $item->setAttribute('identifierref', $r['identifier']);
369
370
            $t = $doc->createElementNS($imscc, 'title');
371
            $title = (string) ($r['title'] ?? $fileName);
372
            $t->nodeValue = '' !== $title ? $title : $fileName;
373
            $item->appendChild($t);
374
375
            $folderParent->appendChild($item);
376
        }
377
378
        $orgs->appendChild($org);
379
        $manifest->appendChild($orgs);
380
381
        // <resources>
382
        $resNode = $doc->createElementNS($imscc, 'resources');
383
        foreach ($resources as $r) {
384
            $res = $doc->createElementNS($imscc, 'resource');
385
            $res->setAttribute('identifier', $r['identifier']);
386
            $res->setAttribute('type', $r['type']);
387
            $res->setAttribute('href', $r['href']);
388
389
            $file = $doc->createElementNS($imscc, 'file');
390
            $file->setAttribute('href', $r['href']);
391
            $res->appendChild($file);
392
393
            $resNode->appendChild($res);
394
        }
395
        $manifest->appendChild($resNode);
396
397
        $doc->appendChild($manifest);
398
399
        $path = $this->workdir.'/imsmanifest.xml';
400
        if (false === file_put_contents($path, $doc->saveXML() ?: '')) {
401
            $this->log('Failed to write imsmanifest.xml', ['path' => $path], 'error');
402
403
            throw new RuntimeException('Failed to write imsmanifest.xml');
404
        }
405
    }
406
407
    /**
408
     * Collect documents from the legacy bag.
409
     * For files, resolve absolute filesystem path via multi-step strategy.
410
     *
411
     * @return array<int,array{src:string, relZip:string, is_dir:bool, debug?:array}>
412
     */
413
    private function collectDocuments(): array
414
    {
415
        $docs = [];
416
417
        $res = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
418
        $docKey = $this->firstExistingKey(
419
            $res,
420
            ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : '']
421
        );
422
423
        if (!$docKey || empty($res[$docKey]) || !\is_array($res[$docKey])) {
424
            $this->log('No "document" bucket found in course bag', [], 'warn');
425
426
            return $docs;
427
        }
428
429
        $this->log('Scanning document bucket', ['items' => \count($res[$docKey])]);
430
431
        foreach ($res[$docKey] as $id => $wrap) {
432
            if (!\is_object($wrap)) {
433
                continue;
434
            }
435
436
            $entity = $this->unwrap($wrap);
437
            $attempts = []; // per-item trace
438
439
            // Path (tolerant)
440
            $raw = (string) ($entity->path ?? $entity->full_path ?? '');
441
            if ('' === $raw) {
442
                $raw = '/document/'.((string) ($entity->title ?? ''));
443
            }
444
445
            $segments = $this->extractDocumentSegmentsFromPath($raw);
446
            if (empty($segments)) {
447
                $this->log('Skipping non-document path', ['raw' => $raw, 'id' => (string) $id], 'debug');
448
449
                continue;
450
            }
451
452
            $fileType = strtolower((string) ($entity->file_type ?? $entity->filetype ?? ''));
453
            $isDir = ('folder' === $fileType) || str_ends_with($raw, '/');
454
455
            $relDoc = implode('/', $segments);
456
            $relZip = 'resources/'.$relDoc;
457
458
            if ($isDir) {
459
                $docs[] = ['src' => '', 'relZip' => rtrim($relZip, '/').'/', 'is_dir' => true, 'debug' => ['note' => 'folder']];
460
                $this->log('Folder queued', ['rel' => $relZip], 'debug');
461
462
                continue;
463
            }
464
465
            // Resolution pipeline
466
            $abs = null;
467
468
            $abs = $this->resolveByWrapperSourceId($wrap, $attempts);
469
            if (!$abs) {
470
                $abs = $this->resolveByEntityResourceNodeBestFile($entity, $attempts);
471
            }
472
            if (!$abs) {
473
                $abs = $this->resolveAbsoluteForDocumentSegments($segments, $attempts);
474
            }
475
            if (!$abs) {
476
                $abs = $this->resolveLegacyFilesystemBySegments($segments, $attempts);
477
            }
478
            $generatedAbs = null;
479
            if (!$abs) {
480
                $generatedAbs = $this->generateFromInlineContentIfAny($entity, $relDoc, $attempts);
481
                if ($generatedAbs) {
482
                    $abs = $generatedAbs;
483
                }
484
            }
485
486
            if (!$abs) {
487
                $this->log('Missing file after pipeline', [
488
                    'raw' => $raw,
489
                    'iid' => (int) ($entity->iid ?? $entity->id ?? 0),
490
                    'attempts' => $attempts,
491
                ], 'warn');
492
493
                $docs[] = [
494
                    'src' => '',
495
                    'relZip' => $relZip,
496
                    'is_dir' => false,
497
                    'debug' => $attempts,
498
                ];
499
500
                continue;
501
            }
502
503
            $docs[] = [
504
                'src' => $abs,
505
                'relZip' => $relZip,
506
                'is_dir' => false,
507
                'debug' => $attempts + ['resolved' => $abs, 'generated' => (bool) $generatedAbs],
508
            ];
509
510
            $this->log('Resolved document', [
511
                'relDoc' => $relDoc,
512
                'abs' => $abs,
513
                'generated' => (bool) $generatedAbs,
514
                'pipeline' => $attempts,
515
            ], 'info');
516
        }
517
518
        return $docs;
519
    }
520
521
    /**
522
     * Collect Web Links from legacy bag.
523
     * Tries common keys and fields: url|href|link, title|name, description|comment.
524
     *
525
     * @return array<int,array{title:string,url:string,description:string}>
526
     */
527
    private function collectWebLinks(): array
528
    {
529
        $out = [];
530
        $res = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
531
532
        $wlKey = $this->firstExistingKey($res, ['weblink', 'weblinks', 'link', 'links', 'Link', 'Links', 'URL', 'Urls', 'urls']);
533
        if (!$wlKey || empty($res[$wlKey]) || !\is_array($res[$wlKey])) {
534
            $this->log('No Web Links bucket found', ['candidates' => $wlKey], 'debug');
535
536
            return $out;
537
        }
538
539
        foreach ($res[$wlKey] as $wrap) {
540
            if (!\is_object($wrap)) {
541
                continue;
542
            }
543
            $e = $this->unwrap($wrap);
544
545
            // URL
546
            $url = (string) ($e->url ?? $e->href ?? $e->link ?? '');
547
            if ('' === $url && !empty($e->path) && \is_string($e->path) && preg_match('~^https?://~i', $e->path)) {
548
                $url = (string) $e->path;
549
            }
550
            if ('' === $url || !preg_match('~^https?://~i', $url)) {
551
                $this->log('Skipping weblink without valid URL', ['raw' => $url], 'debug');
552
553
                continue;
554
            }
555
556
            // Title
557
            $title = (string) ($e->title ?? $e->name ?? $e->label ?? $url);
558
            if ('' === $title) {
559
                $title = $url;
560
            }
561
562
            // Description (optional)
563
            $desc = (string) ($e->description ?? $e->comment ?? $e->content ?? '');
564
565
            $out[] = ['title' => $title, 'url' => $url, 'description' => $desc];
566
        }
567
568
        $this->log('Web Links collected', ['count' => \count($out)], 'info');
569
570
        return $out;
571
    }
572
573
    /**
574
     * Collect Discussion Topics from legacy bag (Chamilo-friendly).
575
     * - Prefer explicit topic buckets: forum_topic | ForumTopic | thread | Thread
576
     * - Pull body from the first post in post/forum_post if topic has no text
577
     * - Fallback: if no topics, create one topic per forum using forum title/description.
578
     *
579
     * @return array<int,array{title:string,body:string}>
580
     */
581
    private function collectDiscussionTopics(): array
582
    {
583
        $out = [];
584
        $res = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
585
586
        // Buckets
587
        $topicKey = $this->firstExistingKey($res, ['forum_topic', 'ForumTopic', 'thread', 'Thread']);
588
        $postKey = $this->firstExistingKey($res, ['forum_post', 'Forum_Post', 'post', 'Post']);
589
        $forumKey = $this->firstExistingKey($res, ['forum', 'Forum']);
590
591
        // Map first post text by thread/topic id
592
        $firstPostByThread = [];
593
        if ($postKey && \is_array($res[$postKey])) {
594
            foreach ($res[$postKey] as $pid => $pWrap) {
595
                if (!\is_object($pWrap)) {
596
                    continue;
597
                }
598
                $p = $this->unwrap($pWrap);
599
                $tid = (int) ($p->thread_id ?? $p->topic_id ?? 0);
600
                if ($tid <= 0) {
601
                    continue;
602
                }
603
604
                // Common fields for post content
605
                $txt = (string) ($p->post_text ?? $p->message ?? $p->text ?? $p->content ?? $p->comment ?? '');
606
                if ('' === $txt) {
607
                    continue;
608
                }
609
610
                // keep the first one we see (starter post)
611
                if (!isset($firstPostByThread[$tid])) {
612
                    $firstPostByThread[$tid] = $txt;
613
                }
614
            }
615
        }
616
617
        // Topics from topic bucket(s)
618
        if ($topicKey && \is_array($res[$topicKey])) {
619
            foreach ($res[$topicKey] as $tid => $tWrap) {
620
                if (!\is_object($tWrap)) {
621
                    continue;
622
                }
623
                $t = $this->unwrap($tWrap);
624
625
                // Title fields typical in Chamilo forum topics/threads
626
                $title = (string) ($t->thread_title ?? $t->title ?? $t->subject ?? '');
627
                if ('' === $title) {
628
                    $title = 'Discussion';
629
                }
630
631
                // Body: look on the topic first, otherwise fallback to first post text
632
                $body = (string) ($t->text ?? $t->description ?? $t->comment ?? $t->content ?? '');
633
                if ('' === $body) {
634
                    $body = $firstPostByThread[(int) ($t->id ?? $tid)]
635
                        ?? $firstPostByThread[(int) ($t->thread_id ?? 0)]
636
                        ?? '';
637
                }
638
639
                $out[] = ['title' => $title, 'body' => $body];
640
            }
641
        }
642
643
        // Fallback: if no explicit topics, create one topic per forum
644
        if (empty($out) && $forumKey && \is_array($res[$forumKey])) {
645
            foreach ($res[$forumKey] as $fid => $fWrap) {
646
                if (!\is_object($fWrap)) {
647
                    continue;
648
                }
649
                $f = $this->unwrap($fWrap);
650
                $title = (string) ($f->forum_title ?? $f->title ?? 'Forum');
651
                $desc = (string) ($f->forum_comment ?? $f->description ?? $f->comment ?? '');
652
                $out[] = ['title' => $title, 'body' => $desc];
653
            }
654
        }
655
656
        $this->log('Discussion Topics collected', ['count' => \count($out)], 'info');
657
658
        return $out;
659
    }
660
661
    /**
662
     * Copy documents into workdir/resources and produce manifest resource list.
663
     *
664
     * @param array<int,array{src:string, relZip:string, is_dir:bool, debug?:array}> $docs
665
     *
666
     * @return array<int,array{identifier:string, href:string, type:string, title?:string, path?:string}>
667
     */
668
    private function copyDocumentsIntoPackage(array $docs): array
669
    {
670
        $out = [];
671
672
        foreach ($docs as $d) {
673
            $targetRel = $d['relZip'];
674
            $targetAbs = $this->workdir.'/'.$targetRel;
675
676
            if ($d['is_dir']) {
677
                $this->mkdirp($targetAbs);
678
679
                continue; // folders are not listed as <resource>
680
            }
681
682
            // Ensure parent dir exists
683
            $this->mkdirp(\dirname($targetAbs));
684
685
            // Copy or write placeholder + diagnostics
686
            if ('' !== $d['src'] && is_file($d['src'])) {
687
                if ($this->pathsAreSame($d['src'], $targetAbs)) {
688
                    // Already generated in place
689
                    $this->log('Skipping copy (already in place)', ['path' => $targetAbs], 'debug');
690
                } elseif (!@copy($d['src'], $targetAbs)) {
691
                    $msg = "Failed to copy source file: {$d['src']}\n";
692
                    @file_put_contents($targetAbs.'.missing.txt', $msg);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

692
                    /** @scrutinizer ignore-unhandled */ @file_put_contents($targetAbs.'.missing.txt', $msg);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
693
                    $this->writeWhyFile($targetAbs, $d['debug'] ?? [], 'copy_failed', $d['src']);
694
                    $targetRel .= '.missing.txt';
695
                    $this->log('Copy failed', ['src' => $d['src'], 'dst' => $targetAbs], 'warn');
696
                }
697
            } else {
698
                @file_put_contents($targetAbs.'.missing.txt', "Missing source file (unresolved)\n");
699
                $this->writeWhyFile($targetAbs, $d['debug'] ?? [], 'unresolved', null);
700
                $targetRel .= '.missing.txt';
701
                $this->log('Wrote missing placeholder', ['dst' => $targetAbs.'.missing.txt'], 'debug');
702
            }
703
704
            // Derive a friendly title and a normalized relative "document" path for organization
705
            $relPathUnderResources = preg_replace('#^resources/#', '', $targetRel) ?? $targetRel;
706
            $relPathUnderResources = ltrim($relPathUnderResources, '/');
707
            $friendlyTitle = basename(rtrim($relPathUnderResources, '/'));
708
709
            // Basic webcontent resource
710
            $out[] = [
711
                'identifier' => 'RES-'.bin2hex(random_bytes(5)),
712
                'href' => $targetRel,
713
                'type' => 'webcontent',
714
                'title' => $friendlyTitle,
715
                'path' => $relPathUnderResources,
716
            ];
717
        }
718
719
        return $out;
720
    }
721
722
    /**
723
     * Extract segments after "/document", e.g. "/document/Folder/file.pdf" → ["Folder","file.pdf"].
724
     *
725
     * @return array<int,string>
726
     */
727
    private function extractDocumentSegmentsFromPath(string $raw): array
728
    {
729
        $decoded = urldecode($raw);
730
        // Remove optional leading slash and "document" root (case-insensitive)
731
        $rel = preg_replace('~^/?document/?~i', '', ltrim($decoded, '/')) ?? '';
732
        $rel = trim($rel, '/');
733
734
        if ('' === $rel) {
735
            return [];
736
        }
737
738
        // Split and normalize
739
        $parts = array_values(array_filter(explode('/', $rel), static fn ($s) => '' !== $s));
740
741
        return array_map(static fn ($s) => trim($s), $parts);
742
    }
743
744
    /**
745
     * STEP 0: Resolve by wrapper source_id (copy scenarios).
746
     */
747
    private function resolveByWrapperSourceId(object $wrap, ?array &$log = null): ?string
748
    {
749
        try {
750
            $sourceId = null;
751
            if (isset($wrap->source_id) && is_numeric($wrap->source_id)) {
752
                $sourceId = (int) $wrap->source_id;
753
            } elseif (isset($wrap->extra['source_id']) && is_numeric($wrap->extra['source_id'])) {
754
                $sourceId = (int) $wrap->extra['source_id'];
755
            }
756
            $this->appendAttempt($log, 'source_id_check', ['source_id' => $sourceId]);
757
758
            if ($sourceId && $sourceId > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $sourceId of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
759
                /** @var CDocument|null $doc */
760
                $doc = $this->em->getRepository(CDocument::class)->find($sourceId);
761
                $this->appendAttempt($log, 'source_id_entity', ['found' => (bool) $doc]);
762
                if ($doc) {
763
                    $tmp = $this->resolveByDocumentEntityBestFile($doc, $log, 'source_id');
764
                    if ($tmp) {
765
                        return $tmp;
766
                    }
767
                }
768
            }
769
        } catch (Throwable $e) {
770
            $this->appendAttempt($log, 'source_id_error', ['error' => $e->getMessage()]);
771
            $this->log('resolveByWrapperSourceId error', ['e' => $e->getMessage()], 'debug');
772
        }
773
774
        return null;
775
    }
776
777
    /**
778
     * STEP 1: Resolve by the actual entity ResourceNode (handles multiple files).
779
     */
780
    private function resolveByEntityResourceNodeBestFile(object $entity, ?array &$log = null): ?string
781
    {
782
        try {
783
            $iid = null;
784
            if (isset($entity->iid) && is_numeric($entity->iid)) {
785
                $iid = (int) $entity->iid;
786
            } elseif (isset($entity->id) && is_numeric($entity->id)) {
787
                $iid = (int) $entity->id;
788
            } elseif (method_exists($entity, 'getIid')) {
789
                $iid = (int) $entity->getIid();
790
            }
791
            $this->appendAttempt($log, 'entity_iid', ['iid' => $iid]);
792
793
            if ($iid && $iid > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $iid of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
794
                /** @var CDocument|null $doc */
795
                $doc = $this->em->getRepository(CDocument::class)->find($iid);
796
                $this->appendAttempt($log, 'entity_doc_found', ['found' => (bool) $doc]);
797
                if ($doc) {
798
                    return $this->resolveByDocumentEntityBestFile($doc, $log, 'entity_rn');
799
                }
800
            }
801
        } catch (Throwable $e) {
802
            $this->appendAttempt($log, 'entity_rn_error', ['error' => $e->getMessage()]);
803
            $this->log('RN resolve error', ['e' => $e->getMessage()], 'debug');
804
        }
805
806
        return null;
807
    }
808
809
    /**
810
     * Resolve best absolute path for any CDocument entity by inspecting all ResourceFiles.
811
     * Strategy: prefer the largest regular file (non-zero size), otherwise the first readable file.
812
     */
813
    private function resolveByDocumentEntityBestFile(CDocument $doc, ?array &$log = null, string $tag = 'rn'): ?string
814
    {
815
        $rn = $doc->getResourceNode();
816
        $this->appendAttempt($log, $tag.'_rn_present', ['present' => (bool) $rn]);
817
        if (!$rn) {
818
            return null;
819
        }
820
821
        // Try "first file" fast-path
822
        if (method_exists($rn, 'getFirstResourceFile') && ($file = $rn->getFirstResourceFile())) {
823
            $abs = $this->absolutePathFromResourceFile($file, $log, $tag.'_first');
824
            if ($abs) {
825
                return $abs;
826
            }
827
        }
828
829
        // Iterate all files if available
830
        if (method_exists($rn, 'getResourceFiles')) {
831
            $bestAbs = null;
832
            $bestSize = -1;
833
834
            foreach ($rn->getResourceFiles() as $idx => $file) {
835
                $abs = $this->absolutePathFromResourceFile($file, $log, $tag.'_iter_'.$idx);
836
                if (!$abs) {
837
                    continue;
838
                }
839
                $sz = @filesize($abs);
840
                if (false === $sz) {
841
                    $sz = -1;
842
                }
843
                $this->appendAttempt($log, $tag.'_iter_size_'.$idx, ['size' => $sz]);
844
                if ($sz > $bestSize) {
845
                    $bestSize = $sz;
846
                    $bestAbs = $abs;
847
                }
848
            }
849
850
            if ($bestAbs) {
851
                $this->appendAttempt($log, $tag.'_best', ['abs' => $bestAbs, 'size' => $bestSize]);
852
853
                return $bestAbs;
854
            }
855
        } else {
856
            $this->appendAttempt($log, $tag.'_no_iter', ['hint' => 'getResourceFiles not available']);
857
        }
858
859
        return null;
860
    }
861
862
    /**
863
     * Convert a ResourceFile into an absolute path under any of the known upload roots.
864
     * Tries several common roots: var/upload/resource, var/data/upload/resource, public/upload/resource, web/upload/resource.
865
     */
866
    private function absolutePathFromResourceFile(object $resourceFile, ?array &$log = null, string $tag = 'rf'): ?string
867
    {
868
        try {
869
            // Most Chamilo builds: repository returns a stored relative filename like "/ab/cd/ef123.bin"
870
            $storedRel = (string) $this->rnRepo->getFilename($resourceFile);
871
            $this->appendAttempt($log, $tag.'_storedRel', ['storedRel' => $storedRel]);
872
873
            if ('' !== $storedRel) {
874
                foreach ($this->getUploadBaseCandidates() as $root) {
875
                    $abs = rtrim($root, '/').$storedRel;
876
                    $ok = (is_readable($abs) && is_file($abs));
877
                    $this->appendAttempt($log, $tag.'_abs_try', ['root' => $root, 'abs' => $abs, 'ok' => $ok]);
878
                    if ($ok) {
879
                        return $abs;
880
                    }
881
                }
882
            }
883
884
            // Some builds might offer a direct path method
885
            if (method_exists($this->rnRepo, 'getAbsolutePath')) {
886
                $abs2 = (string) $this->rnRepo->getAbsolutePath($resourceFile);
887
                $ok2 = ('' !== $abs2 && is_readable($abs2) && is_file($abs2));
888
                $this->appendAttempt($log, $tag.'_abs2_try', ['abs2' => $abs2, 'ok' => $ok2]);
889
                if ($ok2) {
890
                    return $abs2;
891
                }
892
            }
893
        } catch (Throwable $e) {
894
            $this->appendAttempt($log, $tag.'_error', ['error' => $e->getMessage()]);
895
            $this->log('absolutePathFromResourceFile error', ['e' => $e->getMessage()], 'debug');
896
        }
897
898
        return null;
899
    }
900
901
    /**
902
     * Walk children by title from the course documents root and return the absolute file path.
903
     *
904
     * @param array<int,string> $segments
905
     *
906
     * @return string|null Absolute path or null if not found
907
     */
908
    private function resolveAbsoluteForDocumentSegments(array $segments, ?array &$log = null): ?string
909
    {
910
        if (!$this->courseEntity instanceof CourseEntity) {
911
            $this->appendAttempt($log, 'titlewalk_no_course', []);
912
913
            return null;
914
        }
915
916
        $root = $this->docRepo->getCourseDocumentsRootNode($this->courseEntity);
917
        $this->appendAttempt($log, 'titlewalk_root_present', ['present' => (bool) $root]);
918
        if (!$root) {
919
            return null;
920
        }
921
922
        $node = $root;
923
        foreach ($segments as $title) {
924
            $child = $this->docRepo->findChildNodeByTitle($node, $title);
925
            $this->appendAttempt($log, 'titlewalk_step', ['title' => $title, 'found' => (bool) $child]);
926
            if (!$child) {
927
                return null;
928
            }
929
            $node = $child;
930
        }
931
932
        $file = method_exists($node, 'getFirstResourceFile') ? $node->getFirstResourceFile() : null;
933
        $this->appendAttempt($log, 'titlewalk_file_present', ['present' => (bool) $file]);
934
        if (!$file) {
935
            return null;
936
        }
937
938
        $abs = $this->absolutePathFromResourceFile($file, $log, 'titlewalk_rf');
939
        if ($abs && is_readable($abs) && is_file($abs)) {
940
            return $abs;
941
        }
942
943
        return null;
944
    }
945
946
    /**
947
     * Legacy filesystem fallback under courses/<CODE>/document/...
948
     * Tries a few common base paths used in older deployments.
949
     */
950
    private function resolveLegacyFilesystemBySegments(array $segments, ?array &$log = null): ?string
951
    {
952
        if ('' === $this->courseCodeForLegacy) {
953
            $this->appendAttempt($log, 'legacy_no_code', []);
954
955
            return null;
956
        }
957
958
        $bases = [
959
            $this->projectDir.'/var/courses/'.$this->courseCodeForLegacy.'/document',
960
            $this->projectDir.'/app/courses/'.$this->courseCodeForLegacy.'/document',
961
            $this->projectDir.'/courses/'.$this->courseCodeForLegacy.'/document',
962
        ];
963
        $rel = implode('/', $segments);
964
965
        foreach ($bases as $base) {
966
            $cand = rtrim($base, '/').'/'.$rel;
967
            $ok = (is_readable($cand) && is_file($cand));
968
            $this->appendAttempt($log, 'legacy_try', ['candidate' => $cand, 'ok' => $ok]);
969
            if ($ok) {
970
                return $cand;
971
            }
972
        }
973
974
        return null;
975
    }
976
977
    /**
978
     * Some documents are inline HTML stored in DB (no ResourceFile).
979
     * If we can read text content from the entity, generate a temp file to export.
980
     *
981
     * Returns the absolute path of the generated file, or null.
982
     */
983
    private function generateFromInlineContentIfAny(object $entity, string $relDoc, ?array &$log = null): ?string
984
    {
985
        try {
986
            $content = null;
987
            if (method_exists($entity, 'getContent')) {
988
                $content = $entity->getContent();
989
            } elseif (isset($entity->content)) {
990
                $content = $entity->content;
991
            } elseif (isset($entity->comment) && \is_string($entity->comment) && strip_tags($entity->comment) !== $entity->comment) {
992
                $content = $entity->comment;
993
            }
994
995
            $text = (string) ($content ?? '');
996
            $this->appendAttempt($log, 'inline_check', ['hasContent' => ('' !== $text)]);
997
998
            if ('' === $text) {
999
                return null;
1000
            }
1001
1002
            $ext = strtolower(pathinfo($relDoc, PATHINFO_EXTENSION));
1003
            if ('' === $ext) {
1004
                $relDoc .= '.html';
1005
            }
1006
1007
            $generatedAbs = $this->workdir.'/_generated/'.$relDoc;
1008
            $this->mkdirp(\dirname($generatedAbs));
1009
1010
            $payload = $text;
1011
            $looksHtml = false !== stripos($text, '<html') || false !== stripos($text, '<!doctype') || false !== stripos($text, '<body');
1012
            if (!$looksHtml) {
1013
                $payload = "<!doctype html><html><meta charset=\"utf-8\"><body>\n".$text."\n</body></html>";
1014
            }
1015
1016
            if (false === @file_put_contents($generatedAbs, $payload)) {
1017
                $this->appendAttempt($log, 'inline_write_fail', ['path' => $generatedAbs]);
1018
1019
                return null;
1020
            }
1021
1022
            $this->appendAttempt($log, 'inline_generated', ['path' => $generatedAbs]);
1023
1024
            return $generatedAbs;
1025
        } catch (Throwable $e) {
1026
            $this->appendAttempt($log, 'inline_error', ['error' => $e->getMessage()]);
1027
            $this->log('generateFromInlineContentIfAny error', ['e' => $e->getMessage()], 'debug');
1028
1029
            return null;
1030
        }
1031
    }
1032
1033
    private function unwrap(object $o): object
1034
    {
1035
        return (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1036
    }
1037
1038
    /**
1039
     * Get first existing key from a set of candidates.
1040
     */
1041
    private function firstExistingKey(array $arr, array $candidates): ?string
1042
    {
1043
        foreach ($candidates as $k) {
1044
            if ('' === $k || null === $k) {
1045
                continue;
1046
            }
1047
            if (isset($arr[$k]) && \is_array($arr[$k]) && !empty($arr[$k])) {
1048
                return (string) $k;
1049
            }
1050
        }
1051
1052
        return null;
1053
    }
1054
1055
    private function mkdirp(string $path): void
1056
    {
1057
        if (!is_dir($path) && !@mkdir($path, 0775, true) && !is_dir($path)) {
1058
            $this->log('Failed to create directory', ['path' => $path], 'error');
1059
1060
            throw new RuntimeException('Failed to create directory: '.$path);
1061
        }
1062
    }
1063
1064
    private function rrmdir(string $path): void
1065
    {
1066
        if (!is_dir($path)) {
1067
            return;
1068
        }
1069
        $it = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS);
1070
        $ri = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
1071
        foreach ($ri as $file) {
1072
            $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname());
1073
        }
1074
        @rmdir($path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rmdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1074
        /** @scrutinizer ignore-unhandled */ @rmdir($path);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1075
    }
1076
1077
    private function normalizePath(string $p): string
1078
    {
1079
        $p = str_replace('\\', '/', $p);
1080
1081
        return preg_replace('#/+#', '/', $p) ?? $p;
1082
    }
1083
1084
    private function zipDir(string $dir, string $zipPath): void
1085
    {
1086
        $zip = new ZipArchive();
1087
        if (true !== $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
1088
            $this->log('Cannot open zip', ['zipPath' => $zipPath], 'error');
1089
1090
            throw new RuntimeException('Cannot open zip: '.$zipPath);
1091
        }
1092
1093
        $dir = rtrim($dir, DIRECTORY_SEPARATOR);
1094
1095
        $it = new RecursiveIteratorIterator(
1096
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
1097
            RecursiveIteratorIterator::LEAVES_ONLY
1098
        );
1099
1100
        foreach ($it as $file) {
1101
            /** @var SplFileInfo $file */
1102
            if (!$file->isFile()) {
1103
                continue;
1104
            }
1105
            $abs = $file->getRealPath();
1106
            $rel = substr($abs, \strlen($dir) + 1);
1107
            $rel = str_replace('\\', '/', $rel);
1108
            $zip->addFile($abs, $rel);
1109
        }
1110
1111
        $zip->close();
1112
    }
1113
1114
    private function pathsAreSame(string $a, string $b): bool
1115
    {
1116
        $na = $this->normalizePath($a);
1117
        $nb = $this->normalizePath($b);
1118
1119
        return rtrim($na, '/') === rtrim($nb, '/');
1120
    }
1121
1122
    /**
1123
     * Write a companion .why.txt file with pipeline diagnostics for a given missing file.
1124
     */
1125
    private function writeWhyFile(string $targetAbsNoExt, array $attempts, string $reason, ?string $src): void
1126
    {
1127
        try {
1128
            $whyPath = $targetAbsNoExt.'.why.txt';
1129
            $payload = "CC13 resolution diagnostics\n"
1130
                ."Reason: {$reason}\n"
1131
                .'Source: '.($src ?? '(none)')."\n"
1132
                ."Attempts (JSON):\n"
1133
                .json_encode($attempts, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
1134
                ."\n";
1135
            @file_put_contents($whyPath, $payload);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1135
            /** @scrutinizer ignore-unhandled */ @file_put_contents($whyPath, $payload);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1136
        } catch (Throwable $e) {
1137
            $this->log('Failed to write .why.txt', ['dst' => $targetAbsNoExt, 'e' => $e->getMessage()], 'debug');
1138
        }
1139
    }
1140
1141
    /**
1142
     * Write Web Links (IMS CC) files and return resource entries.
1143
     *
1144
     * @param array<int,array{title:string,url:string,description:string}> $links
1145
     *
1146
     * @return array<int,array{identifier:string, href:string, type:string, title:string, path:string}>
1147
     */
1148
    private function writeWebLinksIntoPackage(array $links): array
1149
    {
1150
        $out = [];
1151
        foreach ($links as $idx => $l) {
1152
            $id = 'WL-'.substr(sha1($l['title'].$l['url'].$idx.random_bytes(2)), 0, 10);
1153
            $fn = $this->workdir.'/weblinks/'.$id.'.xml';
1154
            $rel = 'weblinks/'.$id.'.xml';
1155
1156
            // Build minimal IMS WebLink XML (v1p1 works across CC versions)
1157
            $doc = new DOMDocument('1.0', 'UTF-8');
1158
            $doc->formatOutput = true;
1159
1160
            $ns = 'http://www.imsglobal.org/xsd/imswl_v1p1';
1161
            $root = $doc->createElementNS($ns, 'wl:webLink');
1162
1163
            $t = $doc->createElementNS($ns, 'wl:title');
1164
            $t->nodeValue = $l['title'];
1165
            $root->appendChild($t);
1166
1167
            $url = $doc->createElementNS($ns, 'wl:url');
1168
            $url->setAttribute('href', $l['url']);
1169
            $root->appendChild($url);
1170
1171
            if ('' !== $l['description']) {
1172
                $d = $doc->createElementNS($ns, 'wl:description');
1173
                $d->nodeValue = $l['description'];
1174
                $root->appendChild($d);
1175
            }
1176
1177
            $doc->appendChild($root);
1178
            if (false === @file_put_contents($fn, $doc->saveXML() ?: '')) {
1179
                $this->log('Failed to write WebLink XML', ['path' => $fn], 'warn');
1180
1181
                continue;
1182
            }
1183
1184
            $out[] = [
1185
                'identifier' => 'RES-'.substr($id, 3),
1186
                'href' => $rel,
1187
                'type' => 'imswl_xmlv1p1',
1188
                'title' => $l['title'],
1189
                'path' => 'Web Links/'.$l['title'].'.xml',
1190
            ];
1191
        }
1192
1193
        return $out;
1194
    }
1195
1196
    /**
1197
     * Write Discussion Topics (IMS CC) files and return resource entries.
1198
     *
1199
     * @param array<int,array{title:string,body:string}> $topics
1200
     *
1201
     * @return array<int,array{identifier:string, href:string, type:string, title:string, path:string}>
1202
     */
1203
    private function writeDiscussionTopicsIntoPackage(array $topics): array
1204
    {
1205
        $out = [];
1206
        foreach ($topics as $idx => $tpc) {
1207
            $id = 'DT-'.substr(sha1($tpc['title'].$tpc['body'].$idx.random_bytes(2)), 0, 10);
1208
            $fn = $this->workdir.'/discussions/'.$id.'.xml';
1209
            $rel = 'discussions/'.$id.'.xml';
1210
1211
            // Build minimal IMS Discussion Topic XML (v1p1 for broad compatibility)
1212
            $doc = new DOMDocument('1.0', 'UTF-8');
1213
            $doc->formatOutput = true;
1214
1215
            $ns = 'http://www.imsglobal.org/xsd/imsdt_v1p1';
1216
            $root = $doc->createElementNS($ns, 'dt:topic');
1217
1218
            $title = $doc->createElementNS($ns, 'dt:title');
1219
            $title->nodeValue = '' !== $tpc['title'] ? $tpc['title'] : 'Discussion';
1220
            $root->appendChild($title);
1221
1222
            $text = $doc->createElementNS($ns, 'dt:text');
1223
            $text->setAttribute('texttype', 'text/html');
1224
1225
            // Put body inside CDATA to preserve HTML
1226
            $body = '' !== $tpc['body'] ? $tpc['body'] : '';
1227
            $cdata = $doc->createCDATASection($body);
1228
            $text->appendChild($cdata);
1229
1230
            $root->appendChild($text);
1231
            $doc->appendChild($root);
1232
1233
            if (false === @file_put_contents($fn, $doc->saveXML() ?: '')) {
1234
                $this->log('Failed to write Discussion XML', ['path' => $fn], 'warn');
1235
1236
                continue;
1237
            }
1238
1239
            // Safe filename in the logical path used by <organizations>
1240
            $safeTitle = $this->safeFileName('' !== $tpc['title'] ? $tpc['title'] : 'Discussion');
1241
1242
            $out[] = [
1243
                'identifier' => 'RES-'.substr($id, 3),
1244
                'href' => $rel,
1245
                'type' => 'imsdt_xmlv1p1',
1246
                'title' => $tpc['title'],
1247
                'path' => 'Discussions/'.$safeTitle.'.xml',
1248
            ];
1249
        }
1250
1251
        return $out;
1252
    }
1253
1254
    /**
1255
     * Create a filesystem-safe short filename (still human readable).
1256
     */
1257
    private function safeFileName(string $name): string
1258
    {
1259
        $name = trim($name);
1260
        // Replace slashes and other risky chars
1261
        $name = preg_replace('/[\/\x00-\x1F?<>\:\"|*]+/u', '_', $name) ?? 'item';
1262
        // Collapse spaces/underscores and trim length
1263
        $name = preg_replace('/[ _]+/u', ' ', $name) ?? $name;
1264
        $name = trim($name);
1265
        if ('' === $name) {
1266
            $name = 'item';
1267
        }
1268
        // Keep it reasonable for manifest readability
1269
        if (\function_exists('mb_substr')) {
1270
            $name = mb_substr($name, 0, 80);
1271
        } else {
1272
            $name = substr($name, 0, 80);
1273
        }
1274
1275
        return $name;
1276
    }
1277
1278
    /**
1279
     * Environment checks to help diagnose missing files.
1280
     */
1281
    private function checkEnvironment(): void
1282
    {
1283
        // Upload roots
1284
        foreach ($this->getUploadBaseCandidates() as $root) {
1285
            $this->log('Upload root check', [
1286
                'root' => $root,
1287
                'exists' => file_exists($root),
1288
                'is_dir' => is_dir($root),
1289
                'readable' => is_readable($root),
1290
            ], 'debug');
1291
        }
1292
1293
        // Legacy doc dirs
1294
        $paths = [
1295
            'legacy_var_courses' => $this->projectDir.'/var/courses/'.$this->courseCodeForLegacy.'/document',
1296
            'legacy_app_courses' => $this->projectDir.'/app/courses/'.$this->courseCodeForLegacy.'/document',
1297
            'legacy_courses' => $this->projectDir.'/courses/'.$this->courseCodeForLegacy.'/document',
1298
        ];
1299
        foreach ($paths as $name => $path) {
1300
            $this->log('Legacy path check', [
1301
                'name' => $name,
1302
                'path' => $path,
1303
                'exists' => file_exists($path),
1304
                'is_dir' => is_dir($path),
1305
                'readable' => is_readable($path),
1306
            ], 'debug');
1307
        }
1308
    }
1309
1310
    /**
1311
     * Candidate base directories for uploaded ResourceFiles.
1312
     * The storedRel from ResourceNodeRepository::getFilename() is appended to each.
1313
     */
1314
    private function getUploadBaseCandidates(): array
1315
    {
1316
        return [
1317
            $this->projectDir.'/var/upload/resource',
1318
            $this->projectDir.'/var/data/upload/resource',
1319
            $this->projectDir.'/public/upload/resource',
1320
            $this->projectDir.'/web/upload/resource',
1321
        ];
1322
    }
1323
1324
    /**
1325
     * Append a step/attempt into the per-item debug array.
1326
     */
1327
    private function appendAttempt(?array &$log, string $step, array $data): void
1328
    {
1329
        if (null === $log) {
1330
            return;
1331
        }
1332
        $log[] = ['step' => $step, 'data' => $data];
1333
    }
1334
1335
    /**
1336
     * Centralized logger. Writes both to PHP error_log and to a dedicated log file.
1337
     */
1338
    private function log(string $msg, array $ctx = [], string $level = 'info'): void
1339
    {
1340
        if (!$this->debug && 'debug' === $level) {
1341
            return; // skip noisy logs if not in debug
1342
        }
1343
1344
        $line = '[CC13]['.strtoupper($level).'] '.date('c').' '.$msg;
1345
        if (!empty($ctx)) {
1346
            $json = json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1347
            $line .= ' | '.$json;
1348
        }
1349
1350
        // Standard PHP error log
1351
        error_log($line);
1352
1353
        // Dedicated file in /tmp (or system temp dir)
1354
        @error_log($line.PHP_EOL, 3, $this->logFile);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1354
        /** @scrutinizer ignore-unhandled */ @error_log($line.PHP_EOL, 3, $this->logFile);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1355
    }
1356
}
1357