Passed
Push — master ( a64750...1a0efd )
by
unknown
17:28 queued 07:55
created

CourseMaintenanceController::setDebugFromRequest()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 6
eloc 9
c 2
b 1
f 0
nc 6
nop 1
dl 0
loc 16
rs 9.2222
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CoreBundle\Repository\Node\UserRepository;
10
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Capabilities;
11
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Export;
12
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Imscc13Import;
13
use Chamilo\CourseBundle\Component\CourseCopy\CourseArchiver;
14
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
15
use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler;
16
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
17
use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm;
18
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleExport;
19
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleImport;
20
use CourseManager;
21
use Doctrine\ORM\EntityManagerInterface;
22
use RuntimeException;
23
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
24
use stdClass;
25
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
26
use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag};
27
use Symfony\Component\Routing\Attribute\Route;
28
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
29
use Throwable;
30
31
use const ARRAY_FILTER_USE_BOTH;
32
use const JSON_UNESCAPED_SLASHES;
33
use const JSON_UNESCAPED_UNICODE;
34
use const PATHINFO_EXTENSION;
35
36
#[IsGranted('ROLE_TEACHER')]
37
#[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])]
38
class CourseMaintenanceController extends AbstractController
39
{
40
    /**
41
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
42
     */
43
    private bool $debug = true;
44
45
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
46
    public function importOptions(int $node, Request $req): JsonResponse
47
    {
48
        $this->setDebugFromRequest($req);
49
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
50
51
        return $this->json([
52
            'sources' => ['local', 'server'],
53
            'importOptions' => ['full_backup', 'select_items'],
54
            'sameName' => ['skip', 'rename', 'overwrite'],
55
            'defaults' => [
56
                'importOption' => 'full_backup',
57
                'sameName' => 'rename',
58
                'sameFileNameOption' => 2,
59
            ],
60
        ]);
61
    }
62
63
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
64
    public function importUpload(int $node, Request $req): JsonResponse
65
    {
66
        $this->setDebugFromRequest($req);
67
68
        $file = $req->files->get('file');
69
        if (!$file || !$file->isValid()) {
70
            return $this->json(['error' => 'Invalid upload'], 400);
71
        }
72
73
        $maxBytes = 1024 * 1024 * 512;
74
        if ($file->getSize() > $maxBytes) {
75
            return $this->json(['error' => 'File too large'], 413);
76
        }
77
78
        $allowed = ['zip', 'mbz', 'gz', 'tgz'];
79
        $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
80
        if (!\in_array($ext, $allowed, true)) {
81
            return $this->json(['error' => 'Unsupported file type'], 415);
82
        }
83
84
        $this->logDebug('[importUpload] received', [
85
            'original_name' => $file->getClientOriginalName(),
86
            'size' => $file->getSize(),
87
            'mime' => $file->getClientMimeType(),
88
        ]);
89
90
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
91
        if (false === $backupId) {
92
            $this->logDebug('[importUpload] archive dir not writable');
93
94
            return $this->json(['error' => 'Archive directory is not writable'], 500);
95
        }
96
97
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
98
99
        return $this->json([
100
            'backupId' => $backupId,
101
            'filename' => $file->getClientOriginalName(),
102
        ]);
103
    }
104
105
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
106
    public function importServerPick(int $node, Request $req): JsonResponse
107
    {
108
        $this->setDebugFromRequest($req);
109
        $payload = json_decode($req->getContent() ?: '{}', true);
110
111
        $filename = basename((string) ($payload['filename'] ?? ''));
112
        if ('' === $filename || preg_match('/[\/\\\]/', $filename)) {
113
            return $this->json(['error' => 'Invalid filename'], 400);
114
        }
115
116
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
117
        $realBase = realpath(CourseArchiver::getBackupDir());
118
        $realPath = realpath($path);
119
        if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) {
120
            $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]);
121
122
            return $this->json(['error' => 'File not found'], 404);
123
        }
124
125
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
126
127
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
128
    }
129
130
    #[Route(
131
        '/import/{backupId}/resources',
132
        name: 'import_resources',
133
        requirements: ['backupId' => '.+'],
134
        methods: ['GET']
135
    )]
136
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
137
    {
138
        $this->setDebugFromRequest($req);
139
        $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle'
140
141
        $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
142
143
        $this->logDebug('[importResources] course loaded', [
144
            'has_resources' => \is_array($course->resources ?? null),
145
            'keys' => array_keys((array) ($course->resources ?? [])),
146
        ]);
147
148
        $tree = $this->buildResourceTreeForVue($course);
149
150
        $warnings = [];
151
        if (empty($tree)) {
152
            $warnings[] = 'Backup has no selectable resources.';
153
        }
154
155
        return $this->json([
156
            'tree'     => $tree,
157
            'warnings' => $warnings,
158
            'meta'     => ['import_source' => $course->resources['__meta']['import_source'] ?? null],
159
        ]);
160
    }
161
162
    #[Route(
163
        '/import/{backupId}/restore',
164
        name: 'import_restore',
165
        requirements: ['backupId' => '.+'],
166
        methods: ['POST']
167
    )]
168
    public function importRestore(
169
        int $node,
170
        string $backupId,
171
        Request $req,
172
        EntityManagerInterface $em
173
    ): JsonResponse {
174
        $this->setDebugFromRequest($req);
175
        $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]);
176
177
        try {
178
            $payload = json_decode($req->getContent() ?: '{}', true);
179
            // Keep mode consistent with GET /import/{backupId}/resources
180
            $mode = strtolower((string) ($payload['mode'] ?? 'auto'));
181
182
            $importOption = (string) ($payload['importOption'] ?? 'full_backup');
183
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
184
185
            /** @var array<string,array> $selectedResources */
186
            $selectedResources = (array) ($payload['resources'] ?? []);
187
188
            /** @var string[] $selectedTypes */
189
            $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
190
191
            $this->logDebug('[importRestore] input', [
192
                'importOption' => $importOption,
193
                'sameFileNameOption' => $sameFileNameOption,
194
                'selectedTypes' => $selectedTypes,
195
                'hasResourcesMap' => !empty($selectedResources),
196
                'mode' => $mode,
197
            ]);
198
199
            // Load with same mode to avoid switching source on POST
200
            $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
201
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
202
                return $this->json(['error' => 'Backup has no resources'], 400);
203
            }
204
205
            $resourcesAll = $course->resources;
206
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($resourcesAll));
207
208
            // Always hydrate LP dependencies (even in full_backup).
209
            $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll);
210
            $this->logDebug('[importRestore] AFTER hydrate keys', array_keys((array) $course->resources));
211
212
            // Detect source BEFORE any filtering (meta may be dropped by filters)
213
            $importSource = $this->getImportSource($course);
214
            $isMoodle = ('moodle' === $importSource);
215
            $this->logDebug('[importRestore] detected import source', ['import_source' => $importSource, 'isMoodle' => $isMoodle]);
216
217
            if ('select_items' === $importOption) {
218
                if (empty($selectedResources) && !empty($selectedTypes)) {
219
                    $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes);
220
                }
221
222
                $hasAny = false;
223
                foreach ($selectedResources as $ids) {
224
                    if (\is_array($ids) && !empty($ids)) {
225
                        $hasAny = true;
226
                        break;
227
                    }
228
                }
229
                if (!$hasAny) {
230
                    return $this->json(['error' => 'No resources selected'], 400);
231
                }
232
233
                $course = $this->filterLegacyCourseBySelection($course, $selectedResources);
234
                if (empty($course->resources) || 0 === \count((array) $course->resources)) {
235
                    return $this->json(['error' => 'Selection produced no resources to restore'], 400);
236
                }
237
            }
238
239
            $this->logDebug('[importRestore] AFTER filter keys', array_keys((array) $course->resources));
240
241
            // NON-MOODLE
242
            if (!$isMoodle) {
243
                $this->logDebug('[importRestore] non-Moodle backup -> using CourseRestorer');
244
                // Trace around normalization to detect bucket drops
245
                $this->logDebug('[importRestore] BEFORE normalize', array_keys((array) $course->resources));
246
247
                $this->normalizeBucketsForRestorer($course);
248
                $this->logDebug('[importRestore] AFTER  normalize', array_keys((array) $course->resources));
249
250
                $restorer = new CourseRestorer($course);
251
                $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
252
                if (method_exists($restorer, 'setResourcesAllSnapshot')) {
253
                    $restorer->setResourcesAllSnapshot($resourcesAll);
254
                }
255
                if (method_exists($restorer, 'setDebug')) {
256
                    $restorer->setDebug($this->debug);
257
                }
258
                $restorer->restore();
259
260
                CourseArchiver::cleanBackupDir();
261
262
                $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
263
                $redirectUrl = \sprintf('/course/%d/home?sid=0&gid=0', $courseId);
264
265
                return $this->json([
266
                    'ok' => true,
267
                    'message' => 'Import finished',
268
                    'redirectUrl' => $redirectUrl,
269
                ]);
270
            }
271
272
            // MOODLE
273
            $this->logDebug('[importRestore] Moodle backup -> using MoodleImport.*');
274
275
            $backupPath = $this->resolveBackupPath($backupId);
276
            $ci = api_get_course_info();
277
            $cid = (int) ($ci['real_id'] ?? 0);
278
            $sid = 0;
279
280
            $presentBuckets = array_map('strtolower', array_keys((array) $course->resources));
281
            $present = static fn (string $k): bool => \in_array(strtolower($k), $presentBuckets, true);
282
283
            $wantedGroups = [];
284
            $mark = static function (array &$dst, bool $cond, string $key): void { if ($cond) { $dst[$key] = true; } };
285
286
            if ('full_backup' === $importOption) {
287
                // Be tolerant with plural 'documents'
288
                $mark($wantedGroups, $present('link') || $present('link_category'), 'links');
289
                $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums');
290
                $mark($wantedGroups, $present('document') || $present('documents'), 'documents');
291
                $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes');
292
                $mark($wantedGroups, $present('scorm'), 'scorm');
293
            } else {
294
                $mark($wantedGroups, $present('link'), 'links');
295
                $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums');
296
                $mark($wantedGroups, $present('document') || $present('documents'), 'documents');
297
                $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes');
298
                $mark($wantedGroups, $present('scorm'), 'scorm');
299
            }
300
301
            if (empty($wantedGroups)) {
302
                CourseArchiver::cleanBackupDir();
303
304
                return $this->json([
305
                    'ok' => true,
306
                    'message' => 'Nothing to import for Moodle (no supported resource groups present)',
307
                    'stats' => new stdClass(),
308
                ]);
309
            }
310
311
            $importer = new MoodleImport(debug: $this->debug);
312
            $stats = [];
313
314
            // LINKS
315
            if (!empty($wantedGroups['links']) && method_exists($importer, 'restoreLinks')) {
316
                $stats['links'] = $importer->restoreLinks($backupPath, $em, $cid, $sid, $course);
317
            }
318
319
            // FORUMS
320
            if (!empty($wantedGroups['forums']) && method_exists($importer, 'restoreForums')) {
321
                $stats['forums'] = $importer->restoreForums($backupPath, $em, $cid, $sid, $course);
322
            }
323
324
            // DOCUMENTS
325
            if (!empty($wantedGroups['documents']) && method_exists($importer, 'restoreDocuments')) {
326
                $stats['documents'] = $importer->restoreDocuments(
327
                    $backupPath,
328
                    $em,
329
                    $cid,
330
                    $sid,
331
                    $sameFileNameOption,
332
                    $course
333
                );
334
            }
335
336
            // QUIZZES
337
            if (!empty($wantedGroups['quizzes']) && method_exists($importer, 'restoreQuizzes')) {
338
                $stats['quizzes'] = $importer->restoreQuizzes($backupPath, $em, $cid, $sid);
339
            }
340
341
            // SCORM
342
            if (!empty($wantedGroups['scorm']) && method_exists($importer, 'restoreScorm')) {
343
                $stats['scorm'] = $importer->restoreScorm($backupPath, $em, $cid, $sid);
344
            }
345
346
            CourseArchiver::cleanBackupDir();
347
348
            return $this->json([
349
                'ok' => true,
350
                'message' => 'Moodle import finished',
351
                'stats' => $stats,
352
            ]);
353
        } catch (Throwable $e) {
354
            $this->logDebug('[importRestore] exception', [
355
                'message' => $e->getMessage(),
356
                'file' => $e->getFile().':'.$e->getLine(),
357
            ]);
358
359
            return $this->json([
360
                'error' => 'Restore failed: '.$e->getMessage(),
361
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
362
            ], 500);
363
        }
364
    }
365
366
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
367
    public function copyOptions(int $node, Request $req): JsonResponse
368
    {
369
        $this->setDebugFromRequest($req);
370
371
        $current = api_get_course_info();
372
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
373
374
        $courses = [];
375
        foreach ($courseList as $c) {
376
            if ((int) $c['real_id'] === (int) $current['real_id']) {
377
                continue;
378
            }
379
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
380
        }
381
382
        return $this->json([
383
            'courses' => $courses,
384
            'defaults' => [
385
                'copyOption' => 'full_copy',
386
                'includeUsers' => false,
387
                'resetDates' => true,
388
                'sameFileNameOption' => 2,
389
            ],
390
        ]);
391
    }
392
393
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
394
    public function copyResources(int $node, Request $req): JsonResponse
395
    {
396
        $this->setDebugFromRequest($req);
397
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
398
        if ('' === $sourceCourseCode) {
399
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
400
        }
401
402
        $cb = new CourseBuilder();
403
        $cb->set_tools_to_build([
404
            'documents',
405
            'forums',
406
            'tool_intro',
407
            'links',
408
            'quizzes',
409
            'quiz_questions',
410
            'assets',
411
            'surveys',
412
            'survey_questions',
413
            'announcements',
414
            'events',
415
            'course_descriptions',
416
            'glossary',
417
            'wiki',
418
            'thematic',
419
            'attendance',
420
            'works',
421
            'gradebook',
422
            'learnpath_category',
423
            'learnpaths',
424
        ]);
425
426
        $course = $cb->build(0, $sourceCourseCode);
427
428
        $tree = $this->buildResourceTreeForVue($course);
429
430
        $warnings = [];
431
        if (empty($tree)) {
432
            $warnings[] = 'Source course has no resources.';
433
        }
434
435
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
436
    }
437
438
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
439
    public function copyExecute(int $node, Request $req): JsonResponse
440
    {
441
        $this->setDebugFromRequest($req);
442
443
        try {
444
            $payload = json_decode($req->getContent() ?: '{}', true);
445
446
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
447
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
448
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
449
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
450
451
            if ('' === $sourceCourseId) {
452
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
453
            }
454
455
            $cb = new CourseBuilder('partial');
456
            $cb->set_tools_to_build([
457
                'documents',
458
                'forums',
459
                'tool_intro',
460
                'links',
461
                'quizzes',
462
                'quiz_questions',
463
                'assets',
464
                'surveys',
465
                'survey_questions',
466
                'announcements',
467
                'events',
468
                'course_descriptions',
469
                'glossary',
470
                'wiki',
471
                'thematic',
472
                'attendance',
473
                'works',
474
                'gradebook',
475
                'learnpath_category',
476
                'learnpaths',
477
            ]);
478
            $legacyCourse = $cb->build(0, $sourceCourseId);
479
480
            if ('select_items' === $copyOption) {
481
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
482
483
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
484
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
485
                }
486
            }
487
488
            error_log('$legacyCourse :::: '.print_r($legacyCourse, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($legacyCourse, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

488
            error_log('$legacyCourse :::: './** @scrutinizer ignore-type */ print_r($legacyCourse, true));
Loading history...
489
490
            $restorer = new CourseRestorer($legacyCourse);
491
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
492
            if (method_exists($restorer, 'setDebug')) {
493
                $restorer->setDebug($this->debug);
494
            }
495
            $restorer->restore();
496
497
            $dest = api_get_course_info();
498
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
499
500
            return $this->json([
501
                'ok' => true,
502
                'message' => 'Copy finished',
503
                'redirectUrl' => $redirectUrl,
504
            ]);
505
        } catch (Throwable $e) {
506
            return $this->json([
507
                'error' => 'Copy failed: '.$e->getMessage(),
508
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
509
            ], 500);
510
        }
511
    }
512
513
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
514
    public function recycleOptions(int $node, Request $req): JsonResponse
515
    {
516
        $this->setDebugFromRequest($req);
517
518
        // current course only
519
        $defaults = [
520
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
521
            'confirmNeeded' => true,           // show code-confirm input when full
522
        ];
523
524
        return $this->json(['defaults' => $defaults]);
525
    }
526
527
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
528
    public function recycleResources(int $node, Request $req): JsonResponse
529
    {
530
        $this->setDebugFromRequest($req);
531
532
        // Build legacy Course from CURRENT course (not “source”)
533
        $cb = new CourseBuilder();
534
        $cb->set_tools_to_build([
535
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
536
            'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki',
537
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
538
        ]);
539
        $course = $cb->build(0, api_get_course_id());
540
541
        $tree = $this->buildResourceTreeForVue($course);
542
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
543
544
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
545
    }
546
547
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
548
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
549
    {
550
        try {
551
            $p = json_decode($req->getContent() ?: '{}', true);
552
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
553
            $resourcesMap = (array) ($p['resources'] ?? []);
554
            $confirmCode = (string) ($p['confirm'] ?? '');
555
556
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
557
558
            if ('full_backup' === $type) {
559
                if ($confirmCode !== api_get_course_id()) {
560
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
561
                }
562
            } else {
563
                if (empty($resourcesMap)) {
564
                    return $this->json(['error' => 'No resources selected'], 400);
565
                }
566
            }
567
568
            $courseCode = api_get_course_id();
569
            $courseInfo = api_get_course_info($courseCode);
570
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
571
            if ($courseId <= 0) {
572
                return $this->json(['error' => 'Invalid course id'], 400);
573
            }
574
575
            $recycler = new CourseRecycler(
576
                $em,
577
                $courseCode,
578
                $courseId
579
            );
580
581
            $recycler->recycle($type, $resourcesMap);
582
583
            return $this->json([
584
                'ok' => true,
585
                'message' => 'Recycle finished',
586
            ]);
587
        } catch (Throwable $e) {
588
            return $this->json([
589
                'error' => 'Recycle failed: '.$e->getMessage(),
590
            ], 500);
591
        }
592
    }
593
594
    #[Route('/delete', name: 'delete', methods: ['POST'])]
595
    public function deleteCourse(int $node, Request $req): JsonResponse
596
    {
597
        // Basic permission gate (adjust roles to your policy if needed)
598
        if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) {
599
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
600
        }
601
602
        try {
603
            $payload = json_decode($req->getContent() ?: '{}', true);
604
            $confirm = trim((string) ($payload['confirm'] ?? ''));
605
606
            if ('' === $confirm) {
607
                return $this->json(['error' => 'Missing confirmation value'], 400);
608
            }
609
610
            // Current course
611
            $courseInfo = api_get_course_info();
612
            if (empty($courseInfo)) {
613
                return $this->json(['error' => 'Unable to resolve current course'], 400);
614
            }
615
616
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
617
            $runtimeCode = (string) api_get_course_id();                 // often equals official code
618
            $sysCode = (string) ($courseInfo['sysCode'] ?? '');       // used by legacy delete
619
620
            if ('' === $sysCode) {
621
                return $this->json(['error' => 'Invalid course system code'], 400);
622
            }
623
624
            // Accept either official_code or api_get_course_id() as confirmation
625
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
626
            if (!$matches) {
627
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
628
            }
629
630
            // Legacy delete (removes course data + unregisters members in this course)
631
            // Throws on failure or returns void
632
            CourseManager::delete_course($sysCode);
633
634
            // Best-effort cleanup of legacy course session flags
635
            try {
636
                $ses = $req->getSession();
637
                $ses?->remove('_cid');
638
                $ses?->remove('_real_cid');
639
            } catch (Throwable) {
640
                // swallow — not critical
641
            }
642
643
            // Decide where to send the user afterwards
644
            // You can use '/index.php' or a landing page
645
            $redirectUrl = '/index.php';
646
647
            return $this->json([
648
                'ok' => true,
649
                'message' => 'Course deleted successfully',
650
                'redirectUrl' => $redirectUrl,
651
            ]);
652
        } catch (Throwable $e) {
653
            return $this->json([
654
                'error' => 'Failed to delete course: '.$e->getMessage(),
655
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
656
            ], 500);
657
        }
658
    }
659
660
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
661
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
662
    {
663
        $defaults = [
664
            'moodleVersion' => '4',
665
            'scope' => 'full',
666
            'admin' => $users->getDefaultAdminForExport(),
667
        ];
668
669
        $tools = [
670
            ['value' => 'documents', 'label' => 'Documents (files & root HTML pages)'],
671
            ['value' => 'links', 'label' => 'Links (URL)'],
672
            ['value' => 'forums', 'label' => 'Forums'],
673
            ['value' => 'quizzes', 'label' => 'Quizzes', 'implies' => ['quiz_questions']],
674
            ['value' => 'surveys', 'label' => 'Surveys', 'implies' => ['survey_questions']],
675
            ['value' => 'works', 'label' => 'Tasks'],
676
            ['value' => 'glossary', 'label' => 'Glossary'],
677
            ['value' => 'learnpaths', 'label' => 'Paths learning'],
678
            ['value' => 'tool_intro', 'label' => 'Course Introduction'],
679
        ];
680
681
        $defaults['tools'] = array_column($tools, 'value');
682
683
        return $this->json([
684
            'versions' => [
685
                ['value' => '3', 'label' => 'Moodle 3.x'],
686
                ['value' => '4', 'label' => 'Moodle 4.x'],
687
            ],
688
            'tools' => $tools,
689
            'defaults' => $defaults,
690
        ]);
691
    }
692
693
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
694
    public function moodleExportResources(int $node, Request $req): JsonResponse
695
    {
696
        // Enable/disable debug from request
697
        $this->setDebugFromRequest($req);
698
        $this->logDebug('[moodleExportResources] start', ['node' => $node]);
699
700
        try {
701
            // Normalize incoming tools from query (?tools[]=documents&tools[]=links ...)
702
            $selectedTools = $this->normalizeSelectedTools($req->query->all('tools'));
703
704
            // Default toolset tailored for Moodle export picker:
705
            $defaultToolsForMoodle = [
706
                'documents', 'links', 'forums',
707
                'quizzes', 'quiz_questions',
708
                'surveys', 'survey_questions',
709
                'learnpaths', 'learnpath_category',
710
                'works', 'glossary',
711
                'tool_intro',
712
            ];
713
714
            // Use client tools if provided; otherwise our Moodle-safe defaults
715
            $tools = !empty($selectedTools) ? $selectedTools : $defaultToolsForMoodle;
716
717
            // Policy for this endpoint:
718
            //  - Never show gradebook
719
            //  - Always include tool_intro in the picker (harmless if empty)
720
            $tools = array_values(array_diff($tools, ['gradebook']));
721
            if (!in_array('tool_intro', $tools, true)) {
722
                $tools[] = 'tool_intro';
723
            }
724
725
            $this->logDebug('[moodleExportResources] tools to build', $tools);
726
727
            // Build legacy Course snapshot from CURRENT course (not from a source course)
728
            $cb = new CourseBuilder();
729
            $cb->set_tools_to_build($tools);
730
            $course = $cb->build(0, api_get_course_id());
731
732
            // Build the UI-friendly tree
733
            $tree = $this->buildResourceTreeForVue($course);
734
735
            // Basic warnings for the client
736
            $warnings = empty($tree) ? ['This course has no resources.'] : [];
737
738
            // Some compact debug about the resulting tree
739
            if ($this->debug) {
740
                $this->logDebug(
741
                    '[moodleExportResources] tree summary',
742
                    array_map(
743
                        fn ($g) => [
744
                            'type'     => $g['type'] ?? '',
745
                            'title'    => $g['title'] ?? '',
746
                            'items'    => isset($g['items']) ? \count((array) $g['items']) : null,
747
                            'children' => isset($g['children']) ? \count((array) $g['children']) : null,
748
                        ],
749
                        $tree
750
                    )
751
                );
752
            }
753
754
            return $this->json([
755
                'tree'     => $tree,
756
                'warnings' => $warnings,
757
            ]);
758
        } catch (\Throwable $e) {
759
            // Defensive error path
760
            $this->logDebug('[moodleExportResources] exception', [
761
                'message' => $e->getMessage(),
762
                'file'    => $e->getFile().':'.$e->getLine(),
763
            ]);
764
765
            return $this->json([
766
                'error'   => 'Failed to build resource tree for Moodle export.',
767
                'details' => $e->getMessage(),
768
            ], 500);
769
        }
770
    }
771
772
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
773
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): JsonResponse|BinaryFileResponse
774
    {
775
        $this->setDebugFromRequest($req);
776
777
        $p = json_decode($req->getContent() ?: '{}', true);
778
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');
779
        $scope = (string) ($p['scope'] ?? 'full');
780
        $adminId = (int) ($p['adminId'] ?? 0);
781
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
782
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
783
        $selected = (array) ($p['resources'] ?? []);
784
        $toolsInput = (array) ($p['tools'] ?? []);
785
786
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
787
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
788
        }
789
        if ('selected' === $scope && empty($selected)) {
790
            return $this->json(['error' => 'No resources selected'], 400);
791
        }
792
793
        // Normalize tools from client (adds implied deps)
794
        $tools = $this->normalizeSelectedTools($toolsInput);
795
796
        // If scope=selected, merge inferred tools from selection
797
        if ('selected' === $scope) {
798
            $inferred = $this->inferToolsFromSelection($selected);
799
            $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
800
        }
801
802
        $tools = array_values(array_unique(array_diff($tools, ['gradebook'])));
803
        if (!in_array('tool_intro', $tools, true)) {
804
            $tools[] = 'tool_intro';
805
        }
806
807
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
808
            $adm = $users->getDefaultAdminForExport();
809
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
810
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
811
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
812
        }
813
814
        $this->logDebug('[moodleExportExecute] tools for CourseBuilder', $tools);
815
816
        // Build legacy Course from CURRENT course
817
        $cb = new CourseBuilder();
818
        $cb->set_tools_to_build(!empty($tools) ? $tools : [
819
            // Fallback should mirror the Moodle-safe list used in the picker
820
            'documents', 'links', 'forums',
821
            'quizzes', 'quiz_questions',
822
            'surveys', 'survey_questions',
823
            'learnpaths', 'learnpath_category',
824
            'works', 'glossary',
825
            'tool_intro',
826
        ]);
827
        $course = $cb->build(0, api_get_course_id());
828
829
        // IMPORTANT: when scope === 'selected', use the same robust selection filter as copy-course
830
        if ('selected' === $scope) {
831
            // This method trims buckets to only selected items and pulls needed deps (LP/quiz/survey)
832
            $course = $this->filterLegacyCourseBySelection($course, $selected);
833
            // Safety guard: fail if nothing remains after filtering
834
            if (empty($course->resources) || !\is_array($course->resources)) {
835
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
836
            }
837
        }
838
839
        try {
840
            // Pass selection flag to exporter so it does NOT re-hydrate from a complete snapshot.
841
            $selectionMode = ('selected' === $scope);
842
            $exporter = new MoodleExport($course, $selectionMode);
843
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
844
845
            $courseId = api_get_course_id();
846
            $exportDir = 'moodle_export_'.date('Ymd_His');
847
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
848
849
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
850
851
            $resp = new BinaryFileResponse($mbzPath);
852
            $resp->setContentDisposition(
853
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
854
                basename($mbzPath)
855
            );
856
857
            return $resp;
858
        } catch (Throwable $e) {
859
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
860
        }
861
    }
862
863
    /**
864
     * Normalize tool list to supported ones and add implied dependencies.
865
     *
866
     * @param array<int,string>|null $tools
867
     * @return string[]
868
     */
869
    private function normalizeSelectedTools(?array $tools): array
870
    {
871
        // Single list of supported tool buckets (must match CourseBuilder/exporters)
872
        $all = [
873
            'documents','links','quizzes','quiz_questions','surveys','survey_questions',
874
            'announcements','events','course_descriptions','glossary','wiki','thematic',
875
            'attendance','works','gradebook','learnpath_category','learnpaths','tool_intro','forums',
876
        ];
877
878
        // Implied dependencies
879
        $deps = [
880
            'quizzes'    => ['quiz_questions'],
881
            'surveys'    => ['survey_questions'],
882
            'learnpaths' => ['learnpath_category'],
883
        ];
884
885
        $sel = is_array($tools) ? array_values(array_intersect($tools, $all)) : [];
886
887
        foreach ($sel as $t) {
888
            foreach ($deps[$t] ?? [] as $d) {
889
                $sel[] = $d;
890
            }
891
        }
892
893
        // Unique and preserve a sane order based on $all
894
        $sel = array_values(array_unique($sel));
895
        usort($sel, static function ($a, $b) use ($all) {
896
            return array_search($a, $all, true) <=> array_search($b, $all, true);
897
        });
898
899
        return $sel;
900
    }
901
902
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
903
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
904
    {
905
        $this->setDebugFromRequest($req);
906
907
        return $this->json([
908
            'defaults' => ['scope' => 'full'],
909
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
910
            'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.',
911
        ]);
912
    }
913
914
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
915
    public function cc13ExportResources(int $node, Request $req): JsonResponse
916
    {
917
        $this->setDebugFromRequest($req);
918
919
        $cb = new CourseBuilder();
920
        $cb->set_tools_to_build(['documents', 'links', 'forums']);
921
        $course = $cb->build(0, api_get_course_id());
922
923
        $treeAll = $this->buildResourceTreeForVue($course);
924
        $tree    = Cc13Capabilities::filterTree($treeAll);
925
926
        // Count exportables using "items"
927
        $exportableCount = 0;
928
        foreach ($tree as $group) {
929
            if (empty($group['items']) || !\is_array($group['items'])) { continue; }
930
931
            if (($group['type'] ?? '') === 'forum') {
932
                foreach ($group['items'] as $cat) {
933
                    foreach (($cat['items'] ?? []) as $forumNode) {
934
                        if (($forumNode['type'] ?? '') === 'forum') { $exportableCount++; }
935
                    }
936
                }
937
            } else {
938
                $exportableCount += \count($group['items'] ?? []);
939
            }
940
        }
941
942
        $warnings = [];
943
        if ($exportableCount === 0) {
944
            $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).';
945
        }
946
947
        return $this->json([
948
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
949
            'tree'           => $tree,
950
            'preview'        => ['counts' => ['total' => $exportableCount]],
951
            'warnings'       => $warnings,
952
        ]);
953
    }
954
955
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
956
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
957
    {
958
        $payload  = json_decode((string) $req->getContent(), true) ?: [];
959
        // If the client sent "resources", treat as selected even if scope says "full".
960
        $scope    = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full'));
961
        $selected = (array)  ($payload['resources'] ?? []);
962
963
        // Normalize selection structure (documents/links/forums/…)
964
        $normSel = Cc13Capabilities::filterSelection($selected);
965
966
        // Builder setup
967
        $tools = ['documents', 'links', 'forums'];
968
        $cb    = new CourseBuilder();
969
970
        $selectionMode = false;
971
972
        try {
973
            if ($scope === 'selected') {
974
                // Build a full snapshot first to expand any category-only selections.
975
                $cbFull = new CourseBuilder();
976
                $cbFull->set_tools_to_build($tools);
977
                $courseFull = $cbFull->build(0, api_get_course_id());
978
979
                $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel);
980
981
                // Build per-tool ID map for CourseBuilder
982
                $map = [];
983
                if (!empty($expanded['documents'])) { $map['documents'] = array_map('intval', array_keys($expanded['documents'])); }
984
                if (!empty($expanded['links']))     { $map['links']     = array_map('intval', array_keys($expanded['links'])); }
985
                if (!empty($expanded['forums']))    { $map['forums']    = array_map('intval', array_keys($expanded['forums'])); }
986
987
                if (empty($map)) {
988
                    return $this->json(['error' => 'Please select at least one resource.'], 400);
989
                }
990
991
                $cb->set_tools_to_build($tools);
992
                $cb->set_tools_specific_id_list($map);
993
                $selectionMode = true;
994
            } else {
995
                $cb->set_tools_to_build($tools);
996
            }
997
998
            $course = $cb->build(0, api_get_course_id());
999
1000
            // Safety net: if selection mode, ensure resources are filtered
1001
            if ($selectionMode) {
1002
                // Convert to the expected structure for filterCourseResources()
1003
                $safeSelected = [
1004
                    'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),
1005
                    'links'     => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),
1006
                    'forums'    => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),
1007
                ];
1008
                // Also include expansions from categories
1009
                $fullSnapshot = isset($courseFull) ? $courseFull : $course;
1010
                $expandedAll  = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
1011
                foreach (['documents','links','forums'] as $k) {
1012
                    foreach (array_keys($expandedAll[$k] ?? []) as $idStr) {
1013
                        $safeSelected[$k][(int)$idStr] = true;
1014
                    }
1015
                }
1016
1017
                $this->filterCourseResources($course, $safeSelected);
1018
                if (empty($course->resources) || !\is_array($course->resources)) {
1019
                    return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400);
1020
                }
1021
            }
1022
1023
            $exporter  = new Cc13Export($course, $selectionMode, /*debug*/ false);
1024
            $imsccPath = $exporter->export(api_get_course_id());
1025
            $fileName  = basename($imsccPath);
1026
1027
            $downloadUrl = $this->generateUrl(
1028
                    'cm_cc13_export_download',
1029
                    ['node' => $node],
1030
                    UrlGeneratorInterface::ABSOLUTE_URL
1031
                ).'?file='.rawurlencode($fileName);
1032
1033
            return $this->json([
1034
                'ok'          => true,
1035
                'file'        => $fileName,
1036
                'downloadUrl' => $downloadUrl,
1037
                'message'     => 'Export finished.',
1038
            ]);
1039
        } catch (\RuntimeException $e) {
1040
            if (stripos($e->getMessage(), 'Nothing to export') !== false) {
1041
                return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400);
1042
            }
1043
            return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500);
1044
        }
1045
    }
1046
1047
    #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])]
1048
    public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse
1049
    {
1050
        // Validate the filename we will serve
1051
        $file = basename((string) $req->query->get('file', ''));
1052
        // Example pattern: ABC123_cc13_20251017_195455.imscc
1053
        if ($file === '' || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) {
1054
            return $this->json(['error' => 'Invalid file'], 400);
1055
        }
1056
1057
        $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $file;
1058
        if (!is_file($abs)) {
1059
            return $this->json(['error' => 'File not found'], 404);
1060
        }
1061
1062
        // Stream file to the browser
1063
        $resp = new BinaryFileResponse($abs);
1064
        $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file);
1065
        // A sensible CC mime; many LMS aceptan zip también
1066
        $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc');
1067
1068
        return $resp;
1069
    }
1070
1071
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
1072
    public function cc13Import(int $node, Request $req): JsonResponse
1073
    {
1074
        $this->setDebugFromRequest($req);
1075
1076
        try {
1077
            $file = $req->files->get('file');
1078
            if (!$file || !$file->isValid()) {
1079
                return $this->json(['error' => 'Missing or invalid upload.'], 400);
1080
            }
1081
1082
            $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
1083
            if (!in_array($ext, ['imscc', 'zip'], true)) {
1084
                return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415);
1085
            }
1086
1087
            // Move to a temp file
1088
            $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.
1089
                'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext;
1090
            $file->move(dirname($tmpZip), basename($tmpZip));
1091
1092
            // Extract
1093
            $extractDir = Imscc13Import::unzip($tmpZip);
1094
1095
            // Detect and validate format
1096
            $format = Imscc13Import::detectFormat($extractDir);
1097
            if ($format !== Imscc13Import::FORMAT_IMSCC13) {
1098
                Imscc13Import::rrmdir($extractDir);
1099
                @unlink($tmpZip);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

1099
                /** @scrutinizer ignore-unhandled */ @unlink($tmpZip);

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...
1100
1101
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
1102
            }
1103
1104
            // Execute import (creates Chamilo resources)
1105
            $importer = new Imscc13Import();
1106
            $importer->execute($extractDir);
1107
1108
            // Cleanup
1109
            Imscc13Import::rrmdir($extractDir);
1110
            @unlink($tmpZip);
1111
1112
            return $this->json([
1113
                'ok' => true,
1114
                'message' => 'CC 1.3 import completed successfully.',
1115
            ]);
1116
        } catch (\Throwable $e) {
1117
            return $this->json([
1118
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
1119
            ], 500);
1120
        }
1121
    }
1122
1123
    #[Route(
1124
        '/import/{backupId}/diagnose',
1125
        name: 'import_diagnose',
1126
        requirements: ['backupId' => '.+'],
1127
        methods: ['GET']
1128
    )]
1129
    public function importDiagnose(int $node, string $backupId, Request $req): JsonResponse
1130
    {
1131
        $this->setDebugFromRequest($req);
1132
        $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]);
1133
1134
        try {
1135
            // Resolve absolute path of the uploaded/selected backup
1136
            $path = $this->resolveBackupPath($backupId);
1137
            if (!is_file($path)) {
1138
                return $this->json(['error' => 'Backup file not found', 'path' => $path], 404);
1139
            }
1140
1141
            // Read course_info.dat bytes from ZIP
1142
            $ci = $this->readCourseInfoFromZip($path);
1143
            if (empty($ci['ok'])) {
1144
                $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci);
1145
1146
                return $this->json([
1147
                    'meta' => [
1148
                        'backupId' => $backupId,
1149
                        'path'     => $path,
1150
                    ],
1151
                    'zip' => [
1152
                        'error'           => $ci['error'] ?? 'unknown error',
1153
                        'zip_list_sample' => $ci['zip_list_sample'] ?? [],
1154
                        'num_files'       => $ci['num_files'] ?? null,
1155
                    ],
1156
                ], 200);
1157
            }
1158
1159
            $raw  = (string) $ci['data'];
1160
            $size = (int) ($ci['size'] ?? strlen($raw));
1161
            $md5  = md5($raw);
1162
1163
            // Detect & decode content
1164
            $probe = $this->decodeCourseInfo($raw);
1165
1166
            // Build a tiny scan snapshot (only keys, no grafo)
1167
            $scan = [
1168
                'has_graph'      => false,
1169
                'resources_keys' => [],
1170
                'note'           => 'No graph parsed',
1171
            ];
1172
1173
            if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) {
1174
                /** @var object $course */
1175
                $course = $probe['value'];
1176
                $scan['has_graph'] = true;
1177
                $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources))
1178
                    ? array_keys($course->resources)
1179
                    : [];
1180
                $scan['note'] = 'Parsed PHP serialized graph';
1181
            } elseif (!empty($probe['is_json']) && \is_array($probe['json_preview'])) {
1182
                $jp = $probe['json_preview'];
1183
                $scan['has_graph'] = true;
1184
                $scan['resources_keys'] = (isset($jp['resources']) && \is_array($jp['resources']))
1185
                    ? array_keys($jp['resources'])
1186
                    : [];
1187
                $scan['note'] = 'Parsed JSON document';
1188
            }
1189
1190
            $probeOut = $probe;
1191
            unset($probeOut['value'], $probeOut['decoded']);
1192
1193
            $out = [
1194
                'meta' => [
1195
                    'backupId' => $backupId,
1196
                    'path'     => $path,
1197
                    'node'     => $node,
1198
                ],
1199
                'zip' => [
1200
                    'name'  => $ci['name'] ?? null,
1201
                    'index' => $ci['index'] ?? null,
1202
                ],
1203
                'course_info_dat' => [
1204
                    'size_bytes' => $size,
1205
                    'md5'        => $md5,
1206
                ],
1207
                'probe' => $probeOut,
1208
                'scan'  => $scan,
1209
            ];
1210
1211
            $this->logDebug('[importDiagnose] done', [
1212
                'encoding'       => $probeOut['encoding'] ?? null,
1213
                'has_graph'      => $scan['has_graph'],
1214
                'resources_keys' => $scan['resources_keys'],
1215
            ]);
1216
1217
            return $this->json($out);
1218
        } catch (\Throwable $e) {
1219
            $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]);
1220
1221
            return $this->json([
1222
                'error' => 'Diagnosis failed: '.$e->getMessage(),
1223
            ], 500);
1224
        }
1225
    }
1226
1227
    /**
1228
     * Try to detect and decode course_info.dat content.
1229
     * Hardened: preprocess typed-prop numeric strings and register legacy aliases
1230
     * before attempting unserialize. Falls back to relaxed mode to avoid typed
1231
     * property crashes during diagnosis.
1232
     */
1233
    private function decodeCourseInfo(string $raw): array
1234
    {
1235
        $r = [
1236
            'encoding'      => 'raw',
1237
            'decoded_len'   => strlen($raw),
1238
            'magic_hex'     => bin2hex(substr($raw, 0, 8)),
1239
            'magic_ascii'   => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
1240
            'steps'         => [],
1241
            'decoded'       => null,
1242
            'is_serialized' => false,
1243
            'is_json'       => false,
1244
            'json_preview'  => null,
1245
        ];
1246
1247
        $isJson = static function (string $s): bool {
1248
            $t = ltrim($s);
1249
            return $t !== '' && ($t[0] === '{' || $t[0] === '[');
1250
        };
1251
1252
        // Centralized tolerant unserialize with typed-props preprocessing
1253
        $tryUnserializeTolerant = function (string $s, string $label) use (&$r) {
1254
            $ok = false; $val = null; $err = null; $relaxed = false;
1255
1256
            // Ensure legacy aliases and coerce numeric strings before unserialize
1257
            try {
1258
                CourseArchiver::ensureLegacyAliases();
1259
            } catch (\Throwable) { /* ignore */ }
1260
1261
            try {
1262
                $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s);
1263
            } catch (\Throwable) { /* ignore */ }
1264
1265
            // Strict mode
1266
            set_error_handler(static function(){});
1267
            try {
1268
                $val = @unserialize($s, ['allowed_classes' => true]);
1269
                $ok  = ($val !== false) || (trim($s) === 'b:0;');
1270
            } catch (\Throwable $e) {
1271
                $err = $e->getMessage();
1272
                $ok  = false;
1273
            } finally {
1274
                restore_error_handler();
1275
            }
1276
            $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err];
1277
1278
            // Relaxed fallback (no class instantiation) + deincomplete to stdClass
1279
            if (!$ok) {
1280
                $err2 = null;
1281
                set_error_handler(static function(){});
1282
                try {
1283
                    $tmp = @unserialize($s, ['allowed_classes' => false]);
1284
                    if ($tmp !== false || trim($s) === 'b:0;') {
1285
                        $val = $this->deincomplete($tmp);
1286
                        $ok  = true;
1287
                        $relaxed = true;
1288
                        $err = null;
1289
                    }
1290
                } catch (\Throwable $e2) {
1291
                    $err2 = $e2->getMessage();
1292
                } finally {
1293
                    restore_error_handler();
1294
                }
1295
                $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2];
1296
            }
1297
1298
            if ($ok) {
1299
                $r['is_serialized'] = true;
1300
                $r['decoded'] = null; // keep payload minimal
1301
                $r['used_relaxed'] = $relaxed;
1302
                return $val;
1303
            }
1304
            return null;
1305
        };
1306
1307
        // 0) JSON as-is?
1308
        if ($isJson($raw)) {
1309
            $r['encoding'] = 'json';
1310
            $r['is_json']  = true;
1311
            $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1312
1313
            return $r;
1314
        }
1315
1316
        // Direct PHP serialize (strict then relaxed, after preprocessing)
1317
        if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) {
1318
            $r['encoding'] = 'php-serialize';
1319
            return $r + ['value' => $u];
1320
        }
1321
1322
        // GZIP
1323
        if (strncmp($raw, "\x1F\x8B", 2) === 0) {
1324
            $dec = @gzdecode($raw);
1325
            $r['steps'][] = ['action' => 'gzdecode', 'ok' => $dec !== false];
1326
            if ($dec !== false) {
1327
                if ($isJson($dec)) {
1328
                    $r['encoding'] = 'gzip+json';
1329
                    $r['is_json']  = true;
1330
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1331
                    return $r;
1332
                }
1333
                if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) {
1334
                    $r['encoding'] = 'gzip+php-serialize';
1335
                    return $r + ['value' => $u];
1336
                }
1337
            }
1338
        }
1339
1340
        // ZLIB/DEFLATE
1341
        $z2 = substr($raw, 0, 2);
1342
        if ($z2 === "\x78\x9C" || $z2 === "\x78\xDA") {
1343
            $dec = @gzuncompress($raw);
1344
            $r['steps'][] = ['action' => 'gzuncompress', 'ok' => $dec !== false];
1345
            if ($dec !== false) {
1346
                if ($isJson($dec)) {
1347
                    $r['encoding'] = 'zlib+json';
1348
                    $r['is_json']  = true;
1349
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1350
                    return $r;
1351
                }
1352
                if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) {
1353
                    $r['encoding'] = 'zlib+php-serialize';
1354
                    return $r + ['value' => $u];
1355
                }
1356
            }
1357
            $dec2 = @gzinflate($raw);
1358
            $r['steps'][] = ['action' => 'gzinflate', 'ok' => $dec2 !== false];
1359
            if ($dec2 !== false) {
1360
                if ($isJson($dec2)) {
1361
                    $r['encoding'] = 'deflate+json';
1362
                    $r['is_json']  = true;
1363
                    $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1364
                    return $r;
1365
                }
1366
                if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) {
1367
                    $r['encoding'] = 'deflate+php-serialize';
1368
                    return $r + ['value' => $u];
1369
                }
1370
            }
1371
        }
1372
1373
        // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...'))
1374
        if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) {
1375
            $dec = base64_decode($raw, true);
1376
            $r['steps'][] = ['action' => 'base64_decode', 'ok' => $dec !== false];
1377
            if ($dec !== false) {
1378
                if ($isJson($dec)) {
1379
                    $r['encoding'] = 'base64(json)';
1380
                    $r['is_json']  = true;
1381
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1382
                    return $r;
1383
                }
1384
                if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) {
1385
                    $r['encoding'] = 'base64(php-serialize)';
1386
                    return $r + ['value' => $u];
1387
                }
1388
                // base64 + gzip nested
1389
                if (strncmp($dec, "\x1F\x8B", 2) === 0) {
1390
                    $dec2 = @gzdecode($dec);
1391
                    $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => $dec2 !== false];
1392
                    if ($dec2 !== false && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) {
1393
                        $r['encoding'] = 'base64(gzip+php-serialize)';
1394
                        return $r + ['value' => $u];
1395
                    }
1396
                }
1397
            }
1398
        }
1399
1400
        // Nested ZIP?
1401
        if (strncmp($raw, "PK\x03\x04", 4) === 0) {
1402
            $r['encoding'] = 'nested-zip';
1403
        }
1404
1405
        return $r;
1406
    }
1407
1408
    /**
1409
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
1410
     * Also traverses arrays and objects (diagnostics-only).
1411
     */
1412
    private function deincomplete(mixed $v): mixed
1413
    {
1414
        if ($v instanceof \__PHP_Incomplete_Class) {
1415
            $o = new \stdClass();
1416
            foreach (get_object_vars($v) as $k => $vv) {
1417
                $o->{$k} = $this->deincomplete($vv);
1418
            }
1419
            return $o;
1420
        }
1421
        if (is_array($v)) {
1422
            foreach ($v as $k => $vv) {
1423
                $v[$k] = $this->deincomplete($vv);
1424
            }
1425
            return $v;
1426
        }
1427
        if (is_object($v)) {
1428
            foreach (get_object_vars($v) as $k => $vv) {
1429
                $v->{$k} = $this->deincomplete($vv);
1430
            }
1431
            return $v;
1432
        }
1433
        return $v;
1434
    }
1435
1436
    /**
1437
     * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive).
1438
     * Also tries common subpaths, e.g., "course/course_info.dat".
1439
     */
1440
    private function readCourseInfoFromZip(string $zipPath): array
1441
    {
1442
        $candidates = [
1443
            'course_info.dat',
1444
            'course/course_info.dat',
1445
            'backup/course_info.dat',
1446
        ];
1447
1448
        $zip = new \ZipArchive();
1449
        if (true !== ($err = $zip->open($zipPath))) {
1450
            return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')'];
1451
        }
1452
1453
        // First: direct name lookup (case-insensitive)
1454
        $foundIdx = null;
1455
        $foundName = null;
1456
1457
        for ($i = 0; $i < $zip->numFiles; $i++) {
1458
            $st = $zip->statIndex($i);
1459
            if (!$st || !isset($st['name'])) { continue; }
0 ignored issues
show
Bug Best Practice introduced by
The expression $st of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1460
            $name = (string) $st['name'];
1461
            $base = strtolower(basename($name));
1462
            if ($base === 'course_info.dat') {
1463
                $foundIdx = $i;
1464
                $foundName = $name;
1465
                break;
1466
            }
1467
        }
1468
1469
        // Try specific candidate paths if direct scan failed
1470
        if ($foundIdx === null) {
1471
            foreach ($candidates as $cand) {
1472
                $idx = $zip->locateName($cand, \ZipArchive::FL_NOCASE);
1473
                if ($idx !== false) {
1474
                    $foundIdx = $idx;
1475
                    $foundName = $zip->getNameIndex($idx);
1476
                    break;
1477
                }
1478
            }
1479
        }
1480
1481
        if ($foundIdx === null) {
1482
            // Build a short listing for debugging
1483
            $list = [];
1484
            $limit = min($zip->numFiles, 200);
1485
            for ($i = 0; $i < $limit; $i++) {
1486
                $n = $zip->getNameIndex($i);
1487
                if ($n !== false) { $list[] = $n; }
1488
            }
1489
            $zip->close();
1490
1491
            return [
1492
                'ok' => false,
1493
                'error' => 'course_info.dat not found in archive',
1494
                'zip_list_sample' => $list,
1495
                'num_files' => $zip->numFiles,
1496
            ];
1497
        }
1498
1499
        $stat = $zip->statIndex($foundIdx);
1500
        $size = (int) ($stat['size'] ?? 0);
1501
        $fp   = $zip->getStream($foundName);
1502
        if (!$fp) {
0 ignored issues
show
introduced by
$fp is of type resource, thus it always evaluated to false.
Loading history...
1503
            $zip->close();
1504
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1505
        }
1506
1507
        $data = stream_get_contents($fp);
1508
        fclose($fp);
1509
        $zip->close();
1510
1511
        if (!is_string($data)) {
1512
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1513
        }
1514
1515
        return [
1516
            'ok'        => true,
1517
            'name'      => $foundName,
1518
            'index'     => $foundIdx,
1519
            'size'      => $size,
1520
            'data'      => $data,
1521
        ];
1522
    }
1523
1524
    /**
1525
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
1526
     * that reference the selected LearnPaths, taking the items from the full snapshot.
1527
     *
1528
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
1529
     */
1530
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
1531
    {
1532
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
1533
            return;
1534
        }
1535
1536
        $depTypes = [
1537
            'document', 'link', 'quiz', 'work', 'survey',
1538
            'Forum_Category', 'forum', 'thread', 'post',
1539
            'Exercise_Question', 'survey_question', 'Link_Category',
1540
        ];
1541
1542
        $need = [];
1543
        $addNeed = function (string $type, $id) use (&$need): void {
1544
            $t = (string) $type;
1545
            $i = is_numeric($id) ? (int) $id : (string) $id;
1546
            if ('' === $i || 0 === $i) {
1547
                return;
1548
            }
1549
            $need[$t] ??= [];
1550
            $need[$t][$i] = true;
1551
        };
1552
1553
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
1554
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
1555
1556
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
1557
                foreach ($lpWrap->linked_resources as $t => $ids) {
1558
                    if (!\is_array($ids)) {
1559
                        continue;
1560
                    }
1561
                    foreach ($ids as $rid) {
1562
                        $addNeed($t, $rid);
1563
                    }
1564
                }
1565
            }
1566
1567
            $items = [];
1568
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
1569
                $items = $lp->items;
1570
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
1571
                $items = $lpWrap->items;
1572
            }
1573
1574
            foreach ($items as $it) {
1575
                $ito = \is_object($it) ? $it : (object) $it;
1576
1577
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
1578
                    foreach ($ito->linked_resources as $t => $ids) {
1579
                        if (!\is_array($ids)) {
1580
                            continue;
1581
                        }
1582
                        foreach ($ids as $rid) {
1583
                            $addNeed($t, $rid);
1584
                        }
1585
                    }
1586
                }
1587
1588
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
1589
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
1590
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
1591
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
1592
                        if ('' !== $t) {
1593
                            $addNeed($t, $rid);
1594
                        }
1595
                    }
1596
                }
1597
1598
                if (!empty($ito->type) && isset($ito->ref)) {
1599
                    $addNeed((string) $ito->type, $ito->ref);
1600
                }
1601
            }
1602
        }
1603
1604
        if (empty($need)) {
1605
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
1606
            foreach ($core as $k) {
1607
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
1608
                    $course->resources[$k] ??= [];
1609
                    if (0 === \count($course->resources[$k])) {
1610
                        $course->resources[$k] = $snapshot[$k];
1611
                    }
1612
                }
1613
            }
1614
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
1615
                'bags' => array_keys(array_filter($course->resources, fn ($v, $k) => \in_array($k, $core, true) && \is_array($v) && \count($v) > 0, ARRAY_FILTER_USE_BOTH)),
1616
            ]);
1617
1618
            return;
1619
        }
1620
1621
        foreach ($need as $type => $idMap) {
1622
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
1623
                continue;
1624
            }
1625
1626
            $course->resources[$type] ??= [];
1627
1628
            foreach (array_keys($idMap) as $rid) {
1629
                $src = $snapshot[$type][$rid]
1630
                    ?? $snapshot[$type][(string) $rid]
1631
                    ?? null;
1632
1633
                if (!$src) {
1634
                    continue;
1635
                }
1636
1637
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
1638
                    $course->resources[$type][$rid] = $src;
1639
                }
1640
            }
1641
        }
1642
1643
        $this->logDebug('[LP-deps] hydrated', [
1644
            'types' => array_keys($need),
1645
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
1646
        ]);
1647
    }
1648
1649
    /**
1650
     * Build a Vue-friendly tree from legacy Course.
1651
     */
1652
    private function buildResourceTreeForVue(object $course): array
1653
    {
1654
        if ($this->debug) {
1655
            $this->logDebug('[buildResourceTreeForVue] start');
1656
        }
1657
1658
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1659
            ? $course->resources
1660
            : [];
1661
1662
        $legacyTitles = [];
1663
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1664
            /** @var array<string,string> $legacyTitles */
1665
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1666
        }
1667
        $fallbackTitles = $this->getDefaultTypeTitles();
1668
        $skipTypes = $this->getSkipTypeKeys();
1669
1670
        $tree = [];
1671
1672
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1673
            $docs = $resources['document'];
1674
1675
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1676
                $p = trim($rawPath, '/');
1677
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1678
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1679
1680
                // host
1681
                if (!empty($parts) && ($parts[0] === 'localhost' || str_contains($parts[0], '.'))) {
1682
                    array_shift($parts);
1683
                }
1684
                // course-code
1685
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1686
                    array_shift($parts);
1687
                }
1688
1689
                $clean = implode('/', $parts);
1690
                if ($clean === '' && $filetype !== 'folder') {
1691
                    $clean = $title;
1692
                }
1693
                if ($filetype === 'folder') {
1694
                    $clean = rtrim($clean, '/').'/';
1695
                }
1696
                return $clean;
1697
            };
1698
1699
            $folderIdByPath = [];
1700
            foreach ($docs as $obj) {
1701
                if (!\is_object($obj)) { continue; }
1702
                $ft = (string)($obj->filetype ?? $obj->file_type ?? '');
1703
                if ($ft !== 'folder') { continue; }
1704
                $rel = $normalize((string)$obj->path, (string)$obj->title, $ft);
1705
                $key = rtrim($rel, '/');
1706
                if ($key !== '') {
1707
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1708
                }
1709
            }
1710
1711
            $docRoot = [];
1712
            $findChild = static function (array &$children, string $label): ?int {
1713
                foreach ($children as $i => $n) {
1714
                    if ((string)($n['label'] ?? '') === $label) { return $i; }
1715
                }
1716
                return null;
1717
            };
1718
1719
            foreach ($docs as $obj) {
1720
                if (!\is_object($obj)) { continue; }
1721
1722
                $title    = (string) $obj->title;
1723
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1724
                $rel      = $normalize((string) $obj->path, $title, $filetype);
1725
                $parts    = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1726
1727
                $cursor =& $docRoot;
1728
                $soFar = '';
1729
                $total = \count($parts);
1730
1731
                for ($i = 0; $i < $total; $i++) {
1732
                    $seg      = $parts[$i];
1733
                    $isLast   = ($i === $total - 1);
1734
                    $isFolder = (!$isLast) || ($filetype === 'folder');
1735
1736
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1737
                    $label = $seg . ($isFolder ? '/' : '');
1738
1739
                    $idx = $findChild($cursor, $label);
1740
                    if ($idx === null) {
1741
                        if ($isFolder) {
1742
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1743
                            $node = [
1744
                                'id'         => $folderId ?? ('dir:'.$soFar),
1745
                                'label'      => $label,
1746
                                'selectable' => true,
1747
                                'children'   => [],
1748
                            ];
1749
                        } else {
1750
                            $node = [
1751
                                'id'         => (int) $obj->source_id,
1752
                                'label'      => $label,
1753
                                'selectable' => true,
1754
                            ];
1755
                        }
1756
                        $cursor[] = $node;
1757
                        $idx = \count($cursor) - 1;
1758
                    }
1759
1760
                    if ($isFolder) {
1761
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1762
                            $cursor[$idx]['children'] = [];
1763
                        }
1764
                        $cursor =& $cursor[$idx]['children'];
1765
                    }
1766
                }
1767
            }
1768
1769
            $sortTree = null;
1770
            $sortTree = function (array &$nodes) use (&$sortTree) {
1771
                usort($nodes, static fn($a, $b) => strcasecmp((string)$a['label'], (string)$b['label']));
1772
                foreach ($nodes as &$n) {
1773
                    if (isset($n['children']) && \is_array($n['children'])) {
1774
                        $sortTree($n['children']);
1775
                    }
1776
                }
1777
            };
1778
            $sortTree($docRoot);
1779
1780
            $tree[] = [
1781
                'type'     => 'document',
1782
                'title'    => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1783
                'children' => $docRoot,
1784
            ];
1785
1786
            $skipTypes['document'] = true;
1787
        }
1788
1789
        // Forums block
1790
        $hasForumData =
1791
            (!empty($resources['forum']) || !empty($resources['Forum']))
1792
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1793
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1794
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1795
1796
        if ($hasForumData) {
1797
            $tree[] = $this->buildForumTreeForVue(
1798
                $course,
1799
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1800
            );
1801
            $skipTypes['forum'] = true;
1802
            $skipTypes['forum_category'] = true;
1803
            $skipTypes['forum_topic'] = true;
1804
            $skipTypes['forum_post'] = true;
1805
            $skipTypes['thread'] = true;
1806
            $skipTypes['post'] = true;
1807
        }
1808
1809
        // Links block (Category → Link)
1810
        $hasLinkData =
1811
            (!empty($resources['link']) || !empty($resources['Link']))
1812
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1813
1814
        if ($hasLinkData) {
1815
            $tree[] = $this->buildLinkTreeForVue(
1816
                $course,
1817
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1818
            );
1819
            $skipTypes['link'] = true;
1820
            $skipTypes['link_category'] = true;
1821
        }
1822
1823
        foreach ($resources as $rawType => $items) {
1824
            if (!\is_array($items) || empty($items)) {
1825
                continue;
1826
            }
1827
            $typeKey = $this->normalizeTypeKey($rawType);
1828
            if (isset($skipTypes[$typeKey])) {
1829
                continue;
1830
            }
1831
1832
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1833
            $group = [
1834
                'type' => $typeKey,
1835
                'title' => (string) $groupTitle,
1836
                'items' => [],
1837
            ];
1838
1839
            if ('gradebook' === $typeKey) {
1840
                $group['items'][] = [
1841
                    'id' => 'all',
1842
                    'label' => 'Gradebook (all)',
1843
                    'extra' => new stdClass(),
1844
                    'selectable' => true,
1845
                ];
1846
                $tree[] = $group;
1847
                continue;
1848
            }
1849
1850
            foreach ($items as $id => $obj) {
1851
                if (!\is_object($obj)) {
1852
                    continue;
1853
                }
1854
1855
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1856
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1857
                    continue;
1858
                }
1859
1860
                if (!$this->isSelectableItem($typeKey, $obj)) {
1861
                    continue;
1862
                }
1863
1864
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1865
1866
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1867
                    $label = $idKey;
1868
                }
1869
1870
                $extra = $this->buildExtra($typeKey, $obj);
1871
1872
                $group['items'][] = [
1873
                    'id' => $idKey,
1874
                    'label' => $label,
1875
                    'extra' => $extra ?: new stdClass(),
1876
                    'selectable' => true,
1877
                ];
1878
            }
1879
1880
            if (!empty($group['items'])) {
1881
                usort(
1882
                    $group['items'],
1883
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1884
                );
1885
                $tree[] = $group;
1886
            }
1887
        }
1888
1889
        // Preferred order
1890
        $preferredOrder = [
1891
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1892
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
1893
        ];
1894
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1895
            $ia = array_search($a['type'], $preferredOrder, true);
1896
            $ib = array_search($b['type'], $preferredOrder, true);
1897
            if (false !== $ia && false !== $ib) {
1898
                return $ia <=> $ib;
1899
            }
1900
            if (false !== $ia) {
1901
                return -1;
1902
            }
1903
            if (false !== $ib) {
1904
                return 1;
1905
            }
1906
1907
            return strcasecmp($a['title'], $b['title']);
1908
        });
1909
1910
        if ($this->debug) {
1911
            $this->logDebug(
1912
                '[buildResourceTreeForVue] end groups',
1913
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
1914
            );
1915
        }
1916
1917
        return $tree;
1918
    }
1919
1920
1921
    /**
1922
     * Build forum tree (Category → Forum → Topic) for the UI.
1923
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
1924
     */
1925
    private function buildForumTreeForVue(object $course, string $groupTitle): array
1926
    {
1927
        $this->logDebug('[buildForumTreeForVue] start');
1928
1929
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
1930
1931
        // Buckets (defensive: accept legacy casings / aliases)
1932
        $catRaw   = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
1933
        $forumRaw = $res['forum']          ?? $res['Forum']          ?? [];
1934
        $topicRaw = $res['forum_topic']    ?? $res['ForumTopic']     ?? ($res['thread'] ?? []);
1935
        $postRaw  = $res['forum_post']     ?? $res['Forum_Post']     ?? ($res['post'] ?? []);
1936
1937
        $this->logDebug('[buildForumTreeForVue] raw counts', [
1938
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
1939
            'forums'     => \is_array($forumRaw) ? \count($forumRaw) : 0,
1940
            'topics'     => \is_array($topicRaw) ? \count($topicRaw) : 0,
1941
            'posts'      => \is_array($postRaw) ? \count($postRaw) : 0,
1942
        ]);
1943
1944
        // Quick classifiers (defensive)
1945
        $isForum = function (object $o): bool {
1946
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1947
            if (isset($e->forum_title) && \is_string($e->forum_title)) { return true; }
1948
            if (isset($e->default_view) || isset($e->allow_anonymous)) { return true; }
1949
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { return true; }
1950
            return false;
1951
        };
1952
        $isTopic = function (object $o): bool {
1953
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1954
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { return true; }
1955
            if (isset($e->forum_id) && !isset($e->forum_title)) { return true; }
1956
            return false;
1957
        };
1958
        $getForumCategoryId = function (object $forum): int {
1959
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
1960
            $cid = (int) ($e->forum_category ?? 0);
1961
            if ($cid <= 0) { $cid = (int) ($e->forum_category_id ?? 0); }
1962
            if ($cid <= 0) { $cid = (int) ($e->category_id ?? 0); }
1963
            return $cid;
1964
        };
1965
1966
        // Build categories
1967
        $cats = [];
1968
        foreach ($catRaw as $id => $obj) {
1969
            $id = (int) $id;
1970
            if ($id <= 0 || !\is_object($obj)) { continue; }
1971
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
1972
            $cats[$id] = [
1973
                'id'         => $id,
1974
                'type'       => 'forum_category',
1975
                'label'      => ($label !== '' ? $label : 'Category #'.$id).'/',
1976
                'selectable' => true,
1977
                'items'      => [],
1978
                'has_children' => false,
1979
                'item_count'   => 0,
1980
                'extra'      => ['filetype' => 'folder'],
1981
            ];
1982
        }
1983
        // Virtual "Uncategorized"
1984
        $uncatKey = -9999;
1985
        if (!isset($cats[$uncatKey])) {
1986
            $cats[$uncatKey] = [
1987
                'id'           => $uncatKey,
1988
                'type'         => 'forum_category',
1989
                'label'        => 'Uncategorized/',
1990
                'selectable'   => true,
1991
                'items'        => [],
1992
                '_virtual'     => true,
1993
                'has_children' => false,
1994
                'item_count'   => 0,
1995
                'extra'        => ['filetype' => 'folder'],
1996
            ];
1997
        }
1998
1999
        // Forums
2000
        $forums = [];
2001
        foreach ($forumRaw as $id => $obj) {
2002
            $id = (int) $id;
2003
            if ($id <= 0 || !\is_object($obj)) { continue; }
2004
            if (!$isForum($obj)) {
2005
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
2006
                continue;
2007
            }
2008
            $forums[$id] = $this->objectEntity($obj);
2009
        }
2010
2011
        // Topics (+ post counts)
2012
        $topics = [];
2013
        $postCountByTopic = [];
2014
        foreach ($topicRaw as $id => $obj) {
2015
            $id = (int) $id;
2016
            if ($id <= 0 || !\is_object($obj)) { continue; }
2017
            if ($isForum($obj) && !$isTopic($obj)) {
2018
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2019
                continue;
2020
            }
2021
            if (!$isTopic($obj)) { continue; }
2022
            $topics[$id] = $this->objectEntity($obj);
2023
        }
2024
        foreach ($postRaw as $id => $obj) {
2025
            $id = (int) $id;
2026
            if ($id <= 0 || !\is_object($obj)) { continue; }
2027
            $e = $this->objectEntity($obj);
2028
            $tid = (int) ($e->thread_id ?? 0);
2029
            if ($tid > 0) { $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; }
2030
        }
2031
2032
        // Attach topics to forums and forums to categories
2033
        foreach ($forums as $fid => $f) {
2034
            $catId = $getForumCategoryId($f);
2035
            if (!isset($cats[$catId])) { $catId = $uncatKey; }
2036
2037
            $forumNode = [
2038
                'id'         => $fid,
2039
                'type'       => 'forum',
2040
                'label'      => $this->resolveItemLabel('forum', $f, $fid),
2041
                'extra'      => $this->buildExtra('forum', $f) ?: new \stdClass(),
2042
                'selectable' => true,
2043
                'items'      => [],
2044
                // UI hints
2045
                'has_children' => false,
2046
                'item_count'   => 0,
2047
                'ui_depth'     => 2,
2048
            ];
2049
2050
            foreach ($topics as $tid => $t) {
2051
                if ((int) ($t->forum_id ?? 0) !== $fid) { continue; }
2052
2053
                $author  = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2054
                $date    = (string) ($t->thread_date ?? '');
2055
                $nPosts  = (int) ($postCountByTopic[$tid] ?? 0);
2056
2057
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2058
                $meta = [];
2059
                if ($author !== '') { $meta[] = $author; }
2060
                if ($date   !== '') { $meta[] = $date; }
2061
                if ($meta) { $topicLabel .= ' ('.implode(', ', $meta).')'; }
2062
                if ($nPosts > 0) { $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); }
2063
2064
                $forumNode['items'][] = [
2065
                    'id'         => $tid,
2066
                    'type'       => 'forum_topic',
2067
                    'label'      => $topicLabel,
2068
                    'extra'      => new \stdClass(),
2069
                    'selectable' => true,
2070
                    'ui_depth'   => 3,
2071
                    'item_count' => 0,
2072
                ];
2073
            }
2074
2075
            if (!empty($forumNode['items'])) {
2076
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2077
                $forumNode['has_children'] = true;
2078
                $forumNode['item_count']   = \count($forumNode['items']);
2079
            }
2080
2081
            $cats[$catId]['items'][] = $forumNode;
2082
        }
2083
2084
        // Remove empty virtual category; sort forums inside each category
2085
        $catNodes = array_values(array_filter($cats, static function ($c) {
2086
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
2087
            return true;
2088
        }));
2089
2090
        // Flatten stray forums (defensive) and finalize UI hints
2091
        foreach ($catNodes as &$cat) {
2092
            if (!empty($cat['items'])) {
2093
                $lift = [];
2094
                foreach ($cat['items'] as &$forumNode) {
2095
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) { continue; }
2096
                    $keep = [];
2097
                    foreach ($forumNode['items'] as $child) {
2098
                        if (($child['type'] ?? '') === 'forum') {
2099
                            $lift[] = $child;
2100
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2101
                                'parent_forum_id' => $forumNode['id'] ?? null,
2102
                                'lifted_forum_id' => $child['id'] ?? null,
2103
                                'cat_id'          => $cat['id'] ?? null,
2104
                            ]);
2105
                        } else {
2106
                            $keep[] = $child;
2107
                        }
2108
                    }
2109
                    $forumNode['items']        = $keep;
2110
                    $forumNode['has_children'] = !empty($keep);
2111
                    $forumNode['item_count']   = \count($keep);
2112
                }
2113
                unset($forumNode);
2114
2115
                foreach ($lift as $n) { $cat['items'][] = $n; }
2116
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2117
            }
2118
2119
            // UI hints for category
2120
            $cat['has_children'] = !empty($cat['items']);
2121
            $cat['item_count']   = \count($cat['items'] ?? []);
2122
        }
2123
        unset($cat);
2124
2125
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2126
2127
        return [
2128
            'type'  => 'forum',
2129
            'title' => $groupTitle,
2130
            'items' => $catNodes,
2131
        ];
2132
    }
2133
2134
    /**
2135
     * Normalize a raw type to a lowercase key.
2136
     */
2137
    private function normalizeTypeKey(int|string $raw): string
2138
    {
2139
        if (\is_int($raw)) {
2140
            return (string) $raw;
2141
        }
2142
2143
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
2144
2145
        $map = [
2146
            'forum_category' => 'forum_category',
2147
            'forumtopic' => 'forum_topic',
2148
            'forum_topic' => 'forum_topic',
2149
            'forum_post' => 'forum_post',
2150
            'thread' => 'forum_topic',
2151
            'post' => 'forum_post',
2152
            'exercise_question' => 'exercise_question',
2153
            'surveyquestion' => 'survey_question',
2154
            'surveyinvitation' => 'survey_invitation',
2155
            'survey' => 'survey',
2156
            'link_category' => 'link_category',
2157
            'coursecopylearnpath' => 'learnpath',
2158
            'coursecopytestcategory' => 'test_category',
2159
            'coursedescription' => 'course_description',
2160
            'session_course' => 'session_course',
2161
            'gradebookbackup' => 'gradebook',
2162
            'scormdocument' => 'scorm',
2163
            'tool/introduction' => 'tool_intro',
2164
            'tool_introduction' => 'tool_intro',
2165
        ];
2166
2167
        return $map[$s] ?? $s;
2168
    }
2169
2170
    /**
2171
     * Keys to skip as top-level groups in UI.
2172
     *
2173
     * @return array<string,bool>
2174
     */
2175
    private function getSkipTypeKeys(): array
2176
    {
2177
        return [
2178
            'forum_category' => true,
2179
            'forum_topic' => true,
2180
            'forum_post' => true,
2181
            'thread' => true,
2182
            'post' => true,
2183
            'exercise_question' => true,
2184
            'survey_question' => true,
2185
            'survey_invitation' => true,
2186
            'session_course' => true,
2187
            'scorm' => true,
2188
            'asset' => true,
2189
            'link_category' => true,
2190
        ];
2191
    }
2192
2193
    /**
2194
     * Default labels for groups.
2195
     *
2196
     * @return array<string,string>
2197
     */
2198
    private function getDefaultTypeTitles(): array
2199
    {
2200
        return [
2201
            'announcement' => 'Announcements',
2202
            'document' => 'Documents',
2203
            'glossary' => 'Glossaries',
2204
            'calendar_event' => 'Calendar events',
2205
            'event' => 'Calendar events',
2206
            'link' => 'Links',
2207
            'course_description' => 'Course descriptions',
2208
            'learnpath' => 'Parcours',
2209
            'learnpath_category' => 'Learning path categories',
2210
            'forum' => 'Forums',
2211
            'forum_category' => 'Forum categories',
2212
            'quiz' => 'Exercices',
2213
            'test_category' => 'Test categories',
2214
            'wiki' => 'Wikis',
2215
            'thematic' => 'Thematics',
2216
            'attendance' => 'Attendances',
2217
            'work' => 'Works',
2218
            'session_course' => 'Session courses',
2219
            'gradebook' => 'Gradebook',
2220
            'scorm' => 'SCORM packages',
2221
            'survey' => 'Surveys',
2222
            'survey_question' => 'Survey questions',
2223
            'survey_invitation' => 'Survey invitations',
2224
            'asset' => 'Assets',
2225
            'tool_intro' => 'Tool introductions',
2226
        ];
2227
    }
2228
2229
    /**
2230
     * Decide if an item is selectable (UI).
2231
     */
2232
    private function isSelectableItem(string $type, object $obj): bool
2233
    {
2234
        if ('document' === $type) {
2235
            return true;
2236
        }
2237
2238
        return true;
2239
    }
2240
2241
    /**
2242
     * Resolve label for an item with fallbacks.
2243
     */
2244
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2245
    {
2246
        $entity = $this->objectEntity($obj);
2247
2248
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2249
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2250
                return trim((string) $entity->{$k});
2251
            }
2252
        }
2253
2254
        if (isset($obj->params) && \is_array($obj->params)) {
2255
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2256
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2257
                    return (string) $obj->params[$k];
2258
                }
2259
            }
2260
        }
2261
2262
        switch ($type) {
2263
            case 'document':
2264
                // 1) ruta cruda tal como viene del backup/DB
2265
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2266
                if ('' !== $raw) {
2267
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
2268
                    $rel = ltrim($raw, '/');
2269
                    $rel = preg_replace('~^document/?~', '', $rel);
2270
2271
                    // 3) carpeta ⇒ que termine con "/"
2272
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2273
                    if ('folder' === $fileType) {
2274
                        $rel = rtrim($rel, '/').'/';
2275
                    }
2276
2277
                    // 4) si la ruta quedó vacía, usa basename como último recurso
2278
                    return '' !== $rel ? $rel : basename($raw);
2279
                }
2280
2281
                // fallback: título o nombre de archivo
2282
                if (!empty($obj->title)) {
2283
                    return (string) $obj->title;
2284
                }
2285
2286
                break;
2287
2288
            case 'course_description':
2289
                if (!empty($obj->title)) {
2290
                    return (string) $obj->title;
2291
                }
2292
                $t = (int) ($obj->description_type ?? 0);
2293
                $names = [
2294
                    1 => 'Description',
2295
                    2 => 'Objectives',
2296
                    3 => 'Topics',
2297
                    4 => 'Methodology',
2298
                    5 => 'Course material',
2299
                    6 => 'Resources',
2300
                    7 => 'Assessment',
2301
                    8 => 'Custom',
2302
                ];
2303
2304
                return $names[$t] ?? ('#'.$fallbackId);
2305
2306
            case 'announcement':
2307
                if (!empty($obj->title)) {
2308
                    return (string) $obj->title;
2309
                }
2310
2311
                break;
2312
2313
            case 'forum':
2314
                if (!empty($entity->forum_title)) {
2315
                    return (string) $entity->forum_title;
2316
                }
2317
2318
                break;
2319
2320
            case 'forum_category':
2321
                if (!empty($entity->cat_title)) {
2322
                    return (string) $entity->cat_title;
2323
                }
2324
2325
                break;
2326
2327
            case 'link':
2328
                if (!empty($obj->title)) {
2329
                    return (string) $obj->title;
2330
                }
2331
                if (!empty($obj->url)) {
2332
                    return (string) $obj->url;
2333
                }
2334
2335
                break;
2336
2337
            case 'survey':
2338
                if (!empty($obj->title)) {
2339
                    return trim((string) $obj->title);
2340
                }
2341
2342
                break;
2343
2344
            case 'learnpath':
2345
                if (!empty($obj->name)) {
2346
                    return (string) $obj->name;
2347
                }
2348
2349
                break;
2350
2351
            case 'thematic':
2352
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2353
                    return (string) $obj->params['title'];
2354
                }
2355
2356
                break;
2357
2358
            case 'quiz':
2359
                if (!empty($entity->title)) {
2360
                    return (string) $entity->title;
2361
                }
2362
2363
                break;
2364
2365
            case 'forum_topic':
2366
                if (!empty($entity->thread_title)) {
2367
                    return (string) $entity->thread_title;
2368
                }
2369
2370
                break;
2371
        }
2372
2373
        return '#'.$fallbackId;
2374
    }
2375
2376
    /**
2377
     * Extract wrapped entity (->obj) or the object itself.
2378
     */
2379
    private function objectEntity(object $resource): object
2380
    {
2381
        if (isset($resource->obj) && \is_object($resource->obj)) {
2382
            return $resource->obj;
2383
        }
2384
2385
        return $resource;
2386
    }
2387
2388
    /**
2389
     * Extra payload per item for UI (optional).
2390
     */
2391
    private function buildExtra(string $type, object $obj): array
2392
    {
2393
        $extra = [];
2394
2395
        $get = static function (object $o, string $k, $default = null) {
2396
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2397
        };
2398
2399
        switch ($type) {
2400
            case 'document':
2401
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2402
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2403
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2404
2405
                break;
2406
2407
            case 'link':
2408
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2409
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2410
2411
                break;
2412
2413
            case 'forum':
2414
                $entity = $this->objectEntity($obj);
2415
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2416
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2417
2418
                break;
2419
2420
            case 'learnpath':
2421
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2422
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2423
                    return [
2424
                        'id' => (int) ($i['id'] ?? 0),
2425
                        'title' => (string) ($i['title'] ?? ''),
2426
                        'type' => (string) ($i['item_type'] ?? ''),
2427
                        'path' => (string) ($i['path'] ?? ''),
2428
                    ];
2429
                }, $obj->items) : [];
2430
2431
                break;
2432
2433
            case 'thematic':
2434
                if (isset($obj->params) && \is_array($obj->params)) {
2435
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2436
                }
2437
2438
                break;
2439
2440
            case 'quiz':
2441
                $entity = $this->objectEntity($obj);
2442
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2443
                    ? array_map('intval', $entity->question_ids)
2444
                    : [];
2445
2446
                break;
2447
2448
            case 'survey':
2449
                $entity = $this->objectEntity($obj);
2450
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2451
                    ? array_map('intval', $entity->question_ids)
2452
                    : [];
2453
2454
                break;
2455
        }
2456
2457
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2458
    }
2459
2460
    // --------------------------------------------------------------------------------
2461
    // Selection filtering (used by partial restore)
2462
    // --------------------------------------------------------------------------------
2463
2464
    /**
2465
     * Get first existing key from candidates.
2466
     */
2467
    private function firstExistingKey(array $orig, array $candidates): ?string
2468
    {
2469
        foreach ($candidates as $k) {
2470
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2471
                return $k;
2472
            }
2473
        }
2474
2475
        return null;
2476
    }
2477
2478
    /**
2479
     * Filter legacy Course by UI selections (and pull dependencies).
2480
     *
2481
     * @param array $selected [type => [id => true]]
2482
     */
2483
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2484
    {
2485
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2486
        $selected = array_filter($selected, 'is_array');
2487
        unset($selected['undefined']);
2488
2489
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2490
2491
        if (empty($course->resources) || !\is_array($course->resources)) {
2492
            $this->logDebug('[filterSelection] course has no resources');
2493
2494
            return $course;
2495
        }
2496
2497
        /** @var array<string,mixed> $orig */
2498
        $orig = $course->resources;
2499
2500
        // Preserve meta buckets (keys that start with "__")
2501
        $__metaBuckets = [];
2502
        foreach ($orig as $k => $v) {
2503
            if (\is_string($k) && str_starts_with($k, '__')) {
2504
                $__metaBuckets[$k] = $v;
2505
            }
2506
        }
2507
2508
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2509
2510
        // ---------- Forums flow ----------
2511
        if (!empty($selected['forum'])) {
2512
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2513
            if (!empty($selForums)) {
2514
                // tolerant lookups
2515
                $forums  = $this->findBucket($orig, 'forum');
2516
                $threads = $this->findBucket($orig, 'forum_topic');
2517
                $posts   = $this->findBucket($orig, 'forum_post');
2518
2519
                $catsToKeep = [];
2520
2521
                foreach ($forums as $fid => $f) {
2522
                    if (!isset($selForums[(string) $fid])) {
2523
                        continue;
2524
                    }
2525
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2526
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2527
                    if ($cid > 0) {
2528
                        $catsToKeep[$cid] = true;
2529
                    }
2530
                }
2531
2532
                $threadToKeep = [];
2533
                foreach ($threads as $tid => $t) {
2534
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2535
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2536
                        $threadToKeep[(int) $tid] = true;
2537
                    }
2538
                }
2539
2540
                $postToKeep = [];
2541
                foreach ($posts as $pid => $p) {
2542
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2543
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2544
                        $postToKeep[(int) $pid] = true;
2545
                    }
2546
                }
2547
2548
                $out = [];
2549
                foreach ($selected as $type => $ids) {
2550
                    if (!\is_array($ids) || empty($ids)) {
2551
                        continue;
2552
                    }
2553
                    $bucket = $this->findBucket($orig, (string) $type);
2554
                    $key    = $this->findBucketKey($orig, (string) $type);
2555
                    if ($key !== null && !empty($bucket)) {
2556
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2557
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2558
                    }
2559
                }
2560
2561
                $forumCat     = $this->findBucket($orig, 'forum_category');
2562
                $forumBucket  = $this->findBucket($orig, 'forum');
2563
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2564
                $postBucket   = $this->findBucket($orig, 'forum_post');
2565
2566
                if (!empty($forumCat) && !empty($catsToKeep)) {
2567
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2568
                        array_intersect_key(
2569
                            $forumCat,
2570
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2571
                        );
2572
                }
2573
2574
                if (!empty($forumBucket)) {
2575
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2576
                        array_intersect_key($forumBucket, $selForums);
2577
                }
2578
                if (!empty($threadBucket)) {
2579
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2580
                        array_intersect_key(
2581
                            $threadBucket,
2582
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2583
                        );
2584
                }
2585
                if (!empty($postBucket)) {
2586
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2587
                        array_intersect_key(
2588
                            $postBucket,
2589
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2590
                        );
2591
                }
2592
2593
                // If we have forums but no Forum_Category (edge), keep original categories
2594
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2595
                    $out['Forum_Category'] = $forumCat;
2596
                }
2597
2598
                $out = array_filter($out);
2599
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2600
2601
                $this->logDebug('[filterSelection] end (forums)', [
2602
                    'kept_types' => array_keys($course->resources),
2603
                    'forum_counts' => [
2604
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2605
                        'forum'          => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2606
                        'thread'         => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2607
                        'post'           => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2608
                    ],
2609
                ]);
2610
2611
                return $course;
2612
            }
2613
        }
2614
2615
        // ---------- Generic + quiz/survey/gradebook ----------
2616
        $keep = [];
2617
        foreach ($selected as $type => $ids) {
2618
            if (!\is_array($ids) || empty($ids)) {
2619
                continue;
2620
            }
2621
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2622
            if ($legacyKey === null) {
2623
                continue;
2624
            }
2625
            $bucket = $orig[$legacyKey] ?? [];
2626
            if (!empty($bucket) && \is_array($bucket)) {
2627
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2628
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2629
            }
2630
        }
2631
2632
        // Gradebook
2633
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2634
        if ($gbKey && !empty($selected['gradebook'])) {
2635
            $gbBucket = $getBucket($orig, $gbKey);
2636
            if (!empty($gbBucket)) {
2637
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2638
                $firstItem = reset($gbBucket);
2639
2640
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2641
                    $keep[$gbKey] = $gbBucket;
2642
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2643
                } else {
2644
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2645
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2646
                }
2647
            }
2648
        }
2649
2650
        // Quizzes -> questions (+ images)
2651
        $quizKey = $this->firstExistingKey($orig, ['quiz','Quiz']);
2652
        if ($quizKey && !empty($keep[$quizKey])) {
2653
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question','exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2654
            if ($questionKey) {
2655
                $qids = [];
2656
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2657
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2658
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2659
                        foreach ($q->question_ids as $sid) {
2660
                            $qids[(string) $sid] = true;
2661
                        }
2662
                    }
2663
                }
2664
                if (!empty($qids)) {
2665
                    $questionBucket = $getBucket($orig, $questionKey);
2666
                    $selQ = array_intersect_key($questionBucket, $qids);
2667
                    if (!empty($selQ)) {
2668
                        $keep[$questionKey] = $selQ;
2669
2670
                        $docKey = $this->firstExistingKey($orig, ['document','Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2671
                        if ($docKey) {
2672
                            $docBucket = $getBucket($orig, $docKey);
2673
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2674
                            if (!empty($imageQuizBucket)) {
2675
                                $needed = [];
2676
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2677
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2678
                                    $pic = (string) ($q->picture ?? '');
2679
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2680
                                        $needed[$pic] = true;
2681
                                    }
2682
                                }
2683
                                if (!empty($needed)) {
2684
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2685
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2686
                                }
2687
                            }
2688
                        }
2689
                    }
2690
                }
2691
            } else {
2692
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2693
            }
2694
        }
2695
2696
        // Surveys -> questions (+ invitations)
2697
        $surveyKey = $this->firstExistingKey($orig, ['survey','Survey']);
2698
        if ($surveyKey && !empty($keep[$surveyKey])) {
2699
            $surveyQuestionKey   = $this->firstExistingKey($orig, ['Survey_Question','survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2700
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation','survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2701
2702
            if ($surveyQuestionKey) {
2703
                $neededQids   = [];
2704
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2705
2706
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2707
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2708
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2709
                        foreach ($s->question_ids as $qid) {
2710
                            $neededQids[(string) $qid] = true;
2711
                        }
2712
                    }
2713
                }
2714
                if (empty($neededQids)) {
2715
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2716
                    foreach ($surveyQBucket as $qid => $qWrap) {
2717
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2718
                        $qSurveyId = (string) ($q->survey_id ?? '');
2719
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2720
                            $neededQids[(string) $qid] = true;
2721
                        }
2722
                    }
2723
                }
2724
                if (!empty($neededQids)) {
2725
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2726
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2727
                }
2728
            } else {
2729
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2730
            }
2731
2732
            if ($surveyInvitationKey) {
2733
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2734
                if (!empty($invBucket)) {
2735
                    $neededInv = [];
2736
                    foreach ($invBucket as $iid => $invWrap) {
2737
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2738
                        $sid = (string) ($inv->survey_id ?? '');
2739
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2740
                            $neededInv[(string) $iid] = true;
2741
                        }
2742
                    }
2743
                    if (!empty($neededInv)) {
2744
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2745
                    }
2746
                }
2747
            }
2748
        }
2749
2750
        // Documents: add parent folders for selected files
2751
        $docKey = $this->firstExistingKey($orig, ['document','Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2752
        if ($docKey && !empty($keep[$docKey])) {
2753
            $docBucket = $getBucket($orig, $docKey);
2754
2755
            $foldersByRel = [];
2756
            foreach ($docBucket as $fid => $res) {
2757
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2758
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2759
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && substr((string) $e->path, -1) === '/');
2760
                if (!$isFolder) { continue; }
2761
2762
                $p = (string) ($e->path ?? '');
2763
                if ('' === $p) { continue; }
2764
2765
                $frel = '/'.ltrim(substr($p, 8), '/');
2766
                $frel = rtrim($frel, '/').'/';
2767
                if ('//' !== $frel) { $foldersByRel[$frel] = $fid; }
2768
            }
2769
2770
            $needFolderIds = [];
2771
            foreach ($keep[$docKey] as $id => $res) {
2772
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2773
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2774
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && substr((string) $e->path, -1) === '/');
2775
                if ($isFolder) { continue; }
2776
2777
                $p = (string) ($e->path ?? '');
2778
                if ('' === $p) { continue; }
2779
2780
                $rel = '/'.ltrim(substr($p, 8), '/');
2781
                $dir = rtrim(\dirname($rel), '/');
2782
                if ('' === $dir) { continue; }
2783
2784
                $acc = '';
2785
                foreach (array_filter(explode('/', $dir)) as $seg) {
2786
                    $acc .= '/'.$seg;
2787
                    $accKey = rtrim($acc, '/').'/';
2788
                    if (isset($foldersByRel[$accKey])) {
2789
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2790
                    }
2791
                }
2792
            }
2793
            if (!empty($needFolderIds)) {
2794
                $added = array_intersect_key($docBucket, $needFolderIds);
2795
                $keep[$docKey] += $added;
2796
            }
2797
        }
2798
2799
        // Links -> pull categories used by the selected links
2800
        $lnkKey = $this->firstExistingKey($orig, ['link','Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2801
        if ($lnkKey && !empty($keep[$lnkKey])) {
2802
            $catIdsUsed = [];
2803
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2804
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2805
                $cid = (int) ($L->category_id ?? 0);
2806
                if ($cid > 0) { $catIdsUsed[(string) $cid] = true; }
2807
            }
2808
2809
            $catKey = $this->firstExistingKey($orig, ['link_category','Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
2810
            if ($catKey && !empty($catIdsUsed)) {
2811
                $catBucket = $getBucket($orig, $catKey);
2812
                if (!empty($catBucket)) {
2813
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2814
                    $keep[$catKey] = $subset;
2815
                    $keep['link_category'] = $subset; // mirror for convenience
2816
                }
2817
            }
2818
        }
2819
2820
        $keep = array_filter($keep);
2821
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
2822
2823
        $this->logDebug('[filterSelection] non-forum flow end', [
2824
            'selected_types' => array_keys($selected),
2825
            'orig_types'     => array_keys($orig),
2826
            'kept_types'     => array_keys($course->resources ?? []),
2827
        ]);
2828
2829
        return $course;
2830
    }
2831
2832
    /**
2833
     * Map UI options (1/2/3) to legacy file policy.
2834
     */
2835
    private function mapSameNameOption(int $opt): int
2836
    {
2837
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
2838
2839
        if (!\defined('FILE_SKIP')) {
2840
            \define('FILE_SKIP', 1);
2841
        }
2842
        if (!\defined('FILE_RENAME')) {
2843
            \define('FILE_RENAME', 2);
2844
        }
2845
        if (!\defined('FILE_OVERWRITE')) {
2846
            \define('FILE_OVERWRITE', 3);
2847
        }
2848
2849
        return match ($opt) {
2850
            1 => FILE_SKIP,
2851
            3 => FILE_OVERWRITE,
2852
            default => FILE_RENAME,
2853
        };
2854
    }
2855
2856
    /**
2857
     * Set debug mode from Request (query/header).
2858
     */
2859
    private function setDebugFromRequest(?Request $req): void
2860
    {
2861
        if (!$req) {
2862
            return;
2863
        }
2864
        // Query param wins
2865
        if ($req->query->has('debug')) {
2866
            $this->debug = $req->query->getBoolean('debug');
2867
2868
            return;
2869
        }
2870
        // Fallback to header
2871
        $hdr = $req->headers->get('X-Debug');
2872
        if (null !== $hdr) {
2873
            $val = trim((string) $hdr);
2874
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
2875
        }
2876
    }
2877
2878
    /**
2879
     * Debug logger with stage + compact JSON payload.
2880
     */
2881
    private function logDebug(string $stage, mixed $payload = null): void
2882
    {
2883
        if (!$this->debug) {
2884
            return;
2885
        }
2886
        $prefix = 'COURSE_DEBUG';
2887
        if (null === $payload) {
2888
            error_log("$prefix: $stage");
2889
2890
            return;
2891
        }
2892
        // Safe/short json
2893
        $json = null;
2894
2895
        try {
2896
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2897
            if (null !== $json && \strlen($json) > 8000) {
2898
                $json = substr($json, 0, 8000).'…(truncated)';
2899
            }
2900
        } catch (Throwable $e) {
2901
            $json = '[payload_json_error: '.$e->getMessage().']';
2902
        }
2903
        error_log("$prefix: $stage -> $json");
2904
    }
2905
2906
    /**
2907
     * Snapshot of resources bag for quick inspection.
2908
     */
2909
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
0 ignored issues
show
Unused Code introduced by
The method snapshotResources() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
2910
    {
2911
        $out = [];
2912
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2913
        $i = 0;
2914
        foreach ($res as $type => $bag) {
2915
            if ($i++ >= $maxTypes) {
2916
                $out['__notice'] = 'types truncated';
2917
2918
                break;
2919
            }
2920
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
2921
            if (\is_array($bag)) {
2922
                $j = 0;
2923
                foreach ($bag as $id => $obj) {
2924
                    if ($j++ >= $maxItemsPerType) {
2925
                        $snap['sample'][] = ['__notice' => 'truncated'];
2926
2927
                        break;
2928
                    }
2929
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
2930
                    $snap['sample'][] = [
2931
                        'id' => (string) $id,
2932
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
2933
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
2934
                    ];
2935
                }
2936
            }
2937
            $out[(string) $type] = $snap;
2938
        }
2939
2940
        return $out;
2941
    }
2942
2943
    /**
2944
     * Snapshot of forum-family counters.
2945
     */
2946
    private function snapshotForumCounts(object $course): array
0 ignored issues
show
Unused Code introduced by
The method snapshotForumCounts() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
2947
    {
2948
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
2949
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
2950
2951
        return [
2952
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
2953
            'forum' => $get('forum', 'Forum'),
2954
            'thread' => $get('thread', 'forum_topic'),
2955
            'post' => $get('post', 'forum_post'),
2956
        ];
2957
    }
2958
2959
    /**
2960
     * Builds the selection map [type => [id => true]] from high-level types.
2961
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
2962
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
2963
     *
2964
     * @param object   $course        Legacy Course with already hydrated resources
2965
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
2966
     *
2967
     * @return array<string, array<int|string, bool>>
2968
     */
2969
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
2970
    {
2971
        $selectedTypes = array_map(
2972
            fn ($t) => $this->normalizeTypeKey((string) $t),
2973
            $selectedTypes
2974
        );
2975
2976
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2977
2978
        $coreDeps = [
2979
            'document', 'link', 'quiz', 'work', 'survey',
2980
            'Forum_Category', 'forum', 'thread', 'post',
2981
            'exercise_question', 'survey_question', 'link_category',
2982
        ];
2983
2984
        $presentKeys = array_fill_keys(array_map(
2985
            fn ($k) => $this->normalizeTypeKey((string) $k),
2986
            array_keys($res)
2987
        ), true);
2988
2989
        $out = [];
2990
2991
        $addBucket = function (string $typeKey) use (&$out, $res): void {
2992
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
2993
                return;
2994
            }
2995
            $ids = [];
2996
            foreach ($res[$typeKey] as $id => $_) {
2997
                $ids[(string) $id] = true;
2998
            }
2999
            if ($ids) {
3000
                $out[$typeKey] = $ids;
3001
            }
3002
        };
3003
3004
        foreach ($selectedTypes as $t) {
3005
            $addBucket($t);
3006
3007
            if ('learnpath' === $t) {
3008
                foreach ($coreDeps as $depRaw) {
3009
                    $dep = $this->normalizeTypeKey($depRaw);
3010
                    if (isset($presentKeys[$dep])) {
3011
                        $addBucket($dep);
3012
                    }
3013
                }
3014
            }
3015
        }
3016
3017
        $this->logDebug('[buildSelectionFromTypes] built', [
3018
            'selectedTypes' => $selectedTypes,
3019
            'kept_types' => array_keys($out),
3020
        ]);
3021
3022
        return $out;
3023
    }
3024
3025
    /**
3026
     * Build link tree (Category → Link) for the UI.
3027
     * Categories are not selectable; links are leaves (item_count = 0).
3028
     */
3029
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3030
    {
3031
        $this->logDebug('[buildLinkTreeForVue] start');
3032
3033
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3034
        $catRaw  = $res['link_category'] ?? $res['Link_Category'] ?? [];
3035
        $linkRaw = $res['link']          ?? $res['Link']          ?? [];
3036
3037
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3038
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3039
            'links'      => \is_array($linkRaw) ? \count($linkRaw) : 0,
3040
        ]);
3041
3042
        $cats = [];
3043
        foreach ($catRaw as $id => $obj) {
3044
            $id = (int) $id;
3045
            if ($id <= 0 || !\is_object($obj)) { continue; }
3046
            $e = $this->objectEntity($obj);
3047
            $label = $this->resolveItemLabel('link_category', $e, $id);
3048
            $cats[$id] = [
3049
                'id'           => $id,
3050
                'type'         => 'link_category',
3051
                'label'        => (($label !== '' ? $label : ('Category #'.$id)).'/'),
3052
                'selectable'   => true,
3053
                'items'        => [],
3054
                'has_children' => false,
3055
                'item_count'   => 0,
3056
                'extra'        => ['filetype' => 'folder'],
3057
            ];
3058
        }
3059
3060
        // Virtual "Uncategorized"
3061
        $uncatKey = -9999;
3062
        if (!isset($cats[$uncatKey])) {
3063
            $cats[$uncatKey] = [
3064
                'id'           => $uncatKey,
3065
                'type'         => 'link_category',
3066
                'label'        => 'Uncategorized/',
3067
                'selectable'   => true,
3068
                'items'        => [],
3069
                '_virtual'     => true,
3070
                'has_children' => false,
3071
                'item_count'   => 0,
3072
                'extra'        => ['filetype' => 'folder'],
3073
            ];
3074
        }
3075
3076
        // Assign links to categories
3077
        foreach ($linkRaw as $id => $obj) {
3078
            $id = (int) $id;
3079
            if ($id <= 0 || !\is_object($obj)) { continue; }
3080
            $e = $this->objectEntity($obj);
3081
3082
            $cid = (int) ($e->category_id ?? 0);
3083
            if (!isset($cats[$cid])) { $cid = $uncatKey; }
3084
3085
            $cats[$cid]['items'][] = [
3086
                'id'         => $id,
3087
                'type'       => 'link',
3088
                'label'      => $this->resolveItemLabel('link', $e, $id),
3089
                'extra'      => $this->buildExtra('link', $e) ?: new \stdClass(),
3090
                'selectable' => true,
3091
                'item_count' => 0,
3092
            ];
3093
        }
3094
3095
        // Drop empty virtual category, sort, and finalize UI hints
3096
        $catNodes = array_values(array_filter($cats, static function ($c) {
3097
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
3098
            return true;
3099
        }));
3100
3101
        foreach ($catNodes as &$c) {
3102
            if (!empty($c['items'])) {
3103
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3104
            }
3105
            $c['has_children'] = !empty($c['items']);
3106
            $c['item_count']   = \count($c['items'] ?? []);
3107
        }
3108
        unset($c);
3109
3110
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3111
3112
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3113
3114
        return [
3115
            'type'  => 'link',
3116
            'title' => $groupTitle,
3117
            'items' => $catNodes,
3118
        ];
3119
    }
3120
3121
    /**
3122
     * Leaves only the items selected by the UI in $course->resources.
3123
     * Expects $selected with the following form:
3124
     * [
3125
     * "documents" => ["123" => true, "124" => true],
3126
     * "links" => ["7" => true],
3127
     * "quiz" => ["45" => true],
3128
     * ...
3129
     * ].
3130
     */
3131
    private function filterCourseResources(object $course, array $selected): void
3132
    {
3133
        if (!isset($course->resources) || !\is_array($course->resources)) {
3134
            return;
3135
        }
3136
3137
        $typeMap = [
3138
            'documents' => RESOURCE_DOCUMENT,
3139
            'links' => RESOURCE_LINK,
3140
            'quizzes' => RESOURCE_QUIZ,
3141
            'quiz' => RESOURCE_QUIZ,
3142
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3143
            'surveys' => RESOURCE_SURVEY,
3144
            'survey' => RESOURCE_SURVEY,
3145
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3146
            'announcements' => RESOURCE_ANNOUNCEMENT,
3147
            'events' => RESOURCE_EVENT,
3148
            'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
3149
            'glossary' => RESOURCE_GLOSSARY,
3150
            'wiki' => RESOURCE_WIKI,
3151
            'thematic' => RESOURCE_THEMATIC,
3152
            'attendance' => RESOURCE_ATTENDANCE,
3153
            'works' => RESOURCE_WORK,
3154
            'gradebook' => RESOURCE_GRADEBOOK,
3155
            'learnpaths' => RESOURCE_LEARNPATH,
3156
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3157
            'tool_intro' => RESOURCE_TOOL_INTRO,
3158
            'forums' => RESOURCE_FORUM,
3159
            'forum' => RESOURCE_FORUM,
3160
            'forum_topic' => RESOURCE_FORUMTOPIC,
3161
            'forum_post' => RESOURCE_FORUMPOST,
3162
        ];
3163
3164
        $allowed = [];
3165
        foreach ($selected as $k => $idsMap) {
3166
            $key = $typeMap[$k] ?? $k;
3167
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3168
        }
3169
3170
        foreach ($course->resources as $rtype => $bucket) {
3171
            if (!isset($allowed[$rtype])) {
3172
                continue;
3173
            }
3174
            $keep = $allowed[$rtype];
3175
            $filtered = [];
3176
            foreach ((array) $bucket as $id => $obj) {
3177
                $iid = (int) ($obj->source_id ?? $id);
3178
                if (isset($keep[$iid])) {
3179
                    $filtered[$id] = $obj;
3180
                }
3181
            }
3182
            $course->resources[$rtype] = $filtered;
3183
        }
3184
    }
3185
3186
    /**
3187
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3188
     */
3189
    private function resolveBackupPath(string $backupId): string
3190
    {
3191
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3192
        $baseReal = realpath($base) ?: $base;
3193
3194
        $file = basename($backupId);
3195
        $path = $baseReal . DIRECTORY_SEPARATOR . $file;
3196
3197
        $real = realpath($path);
3198
3199
        if ($real !== false && strncmp($real, $baseReal, strlen($baseReal)) === 0) {
3200
            return $real;
3201
        }
3202
3203
        return $path;
3204
    }
3205
3206
    /**
3207
     * Load a legacy Course object from any backup:
3208
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3209
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3210
     *
3211
     * IMPORTANT:
3212
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3213
     * - Tries Moodle only when the package looks like Moodle.
3214
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3215
     */
3216
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3217
    {
3218
        $path = $this->resolveBackupPath($backupId);
3219
3220
        $force = strtolower($force);
3221
        if ('dat' === $force || 'chamilo' === $force) {
3222
            $looksMoodle = false;
3223
            $preferChamilo = true;
3224
        } elseif ('moodle' === $force) {
3225
            $looksMoodle = true;
3226
            $preferChamilo = false;
3227
        } else {
3228
            $looksMoodle   = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3229
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3230
        }
3231
3232
        if ($preferChamilo || !$looksMoodle) {
3233
            CourseArchiver::setDebug($this->debug);
3234
3235
            try {
3236
                $course = CourseArchiver::readCourse($backupId, false);
3237
                if (\is_object($course)) {
3238
                    // … (resto igual)
3239
                    if (!isset($course->resources) || !\is_array($course->resources)) { $course->resources = []; }
3240
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3241
                    $course->resources['__meta']['import_source'] = 'chamilo';
3242
                    return $course;
3243
                }
3244
            } catch (\Throwable $e) {
3245
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3246
            }
3247
3248
            $zipPath = $this->resolveBackupPath($backupId);
3249
            $ci = $this->readCourseInfoFromZip($zipPath);
3250
            if (empty($ci['ok'])) {
3251
                if ($looksMoodle) {
3252
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3253
                    return $this->loadMoodleCourseOrFail($path);
3254
                }
3255
                throw new \RuntimeException('course_info.dat not found in backup');
3256
            }
3257
3258
            $raw = (string) $ci['data'];
3259
            $payload = base64_decode($raw, true);
3260
            if ($payload === false) { $payload = $raw; }
3261
3262
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3263
            CourseArchiver::ensureLegacyAliases();
3264
3265
            set_error_handler(static function () {});
3266
            try {
3267
                if (class_exists(\UnserializeApi::class)) {
3268
                    $c = \UnserializeApi::unserialize('course', $payload);
3269
                } else {
3270
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3271
                }
3272
            } finally {
3273
                restore_error_handler();
3274
            }
3275
3276
            if (!\is_object($c ?? null)) {
3277
                if ($looksMoodle) {
3278
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3279
                    return $this->loadMoodleCourseOrFail($path);
3280
                }
3281
                throw new \RuntimeException('Could not unserialize course (fallback)');
3282
            }
3283
3284
            if (!isset($c->resources) || !\is_array($c->resources)) { $c->resources = []; }
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $c does not seem to be defined for all execution paths leading up to this point.
Loading history...
3285
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3286
            $c->resources['__meta']['import_source'] = 'chamilo';
3287
3288
            return $c;
3289
        }
3290
3291
        // Moodle path
3292
        if ($looksMoodle) {
3293
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3294
            return $this->loadMoodleCourseOrFail($path);
3295
        }
3296
3297
        throw new \RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3298
    }
3299
3300
    /**
3301
     * Normalize resource buckets to the exact keys supported by CourseRestorer.
3302
     * Only the canonical keys below are produced; common aliases are mapped.
3303
     * - Never drop data: merge buckets; keep __meta as-is.
3304
     * - Make sure "document" survives if it existed before.
3305
     */
3306
    private function normalizeBucketsForRestorer(object $course): void
3307
    {
3308
        if (!isset($course->resources) || !\is_array($course->resources)) {
3309
            return;
3310
        }
3311
3312
        // Canonical keys -> constants used by the restorer (reference only)
3313
        $allowed = [
3314
            'link'              => \defined('RESOURCE_LINK') ? RESOURCE_LINK : null,
3315
            'link_category'     => \defined('RESOURCE_LINKCATEGORY') ? RESOURCE_LINKCATEGORY : null,
3316
            'forum'             => \defined('RESOURCE_FORUM') ? RESOURCE_FORUM : null,
3317
            'forum_category'    => \defined('RESOURCE_FORUMCATEGORY') ? RESOURCE_FORUMCATEGORY : null,
3318
            'forum_topic'       => \defined('RESOURCE_FORUMTOPIC') ? RESOURCE_FORUMTOPIC : null,
3319
            'forum_post'        => \defined('RESOURCE_FORUMPOST') ? RESOURCE_FORUMPOST : null,
3320
            'document'          => \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : null,
3321
            'quiz'              => \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : null,
3322
            'exercise_question' => \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : null,
3323
            'survey'            => \defined('RESOURCE_SURVEY') ? RESOURCE_SURVEY : null,
3324
            'survey_question'   => \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : null,
3325
            'tool_intro'        => \defined('RESOURCE_TOOL_INTRO') ? RESOURCE_TOOL_INTRO : null,
3326
        ];
3327
3328
        // Minimal, well-scoped alias map (input -> canonical)
3329
        $alias = [
3330
            // docs
3331
            'documents'            => 'document',
3332
            'document'             => 'document',
3333
            'document '            => 'document',
3334
            'Document'             => 'document',
3335
3336
            // tool intro
3337
            'tool introduction'    => 'tool_intro',
3338
            'tool_introduction'    => 'tool_intro',
3339
            'tool/introduction'    => 'tool_intro',
3340
            'tool intro'           => 'tool_intro',
3341
            'Tool introduction'    => 'tool_intro',
3342
3343
            // forums
3344
            'forum'                => 'forum',
3345
            'forums'               => 'forum',
3346
            'forum_category'       => 'forum_category',
3347
            'Forum_Category'       => 'forum_category',
3348
            'forumcategory'        => 'forum_category',
3349
            'forum_topic'          => 'forum_topic',
3350
            'forumtopic'           => 'forum_topic',
3351
            'thread'               => 'forum_topic',
3352
            'forum_post'           => 'forum_post',
3353
            'forumpost'            => 'forum_post',
3354
            'post'                 => 'forum_post',
3355
3356
            // links
3357
            'link'                 => 'link',
3358
            'links'                => 'link',
3359
            'link_category'        => 'link_category',
3360
            'link category'        => 'link_category',
3361
3362
            // quiz + questions
3363
            'quiz'                 => 'quiz',
3364
            'exercise_question'    => 'exercise_question',
3365
            'Exercise_Question'    => 'exercise_question',
3366
            'exercisequestion'     => 'exercise_question',
3367
3368
            // surveys
3369
            'survey'               => 'survey',
3370
            'surveys'              => 'survey',
3371
            'survey_question'      => 'survey_question',
3372
            'surveyquestion'       => 'survey_question',
3373
        ];
3374
3375
        $before = $course->resources;
3376
3377
        // Keep meta buckets verbatim
3378
        $meta = [];
3379
        foreach ($before as $k => $v) {
3380
            if (\is_string($k) && str_starts_with($k, '__')) {
3381
                $meta[$k] = $v;
3382
                unset($before[$k]);
3383
            }
3384
        }
3385
3386
        $hadDocument =
3387
            isset($before['document']) ||
3388
            isset($before['Document']) ||
3389
            isset($before['documents']);
3390
3391
        // Merge helper (preserve numeric/string ids)
3392
        $merge = static function (array $dst, array $src): array {
3393
            foreach ($src as $id => $obj) {
3394
                if (!array_key_exists($id, $dst)) {
3395
                    $dst[$id] = $obj;
3396
                }
3397
            }
3398
            return $dst;
3399
        };
3400
3401
        $out = [];
3402
3403
        foreach ($before as $rawKey => $bucket) {
3404
            if (!\is_array($bucket)) {
3405
                // Unexpected shape; skip silently (defensive)
3406
                continue;
3407
            }
3408
3409
            // Normalize key shape first
3410
            $k = (string) $rawKey;
3411
            $norm = strtolower(trim($k));
3412
            $norm = strtr($norm, ['\\' => '/', '-' => '_']);  // cheap normalization
3413
            // Map via alias table if present
3414
            $canon = $alias[$norm] ?? $alias[str_replace('/', '_', $norm)] ?? null;
3415
3416
            // If still unknown, try a sane guess: underscores + lowercase
3417
            if (null === $canon) {
3418
                $guess = str_replace(['/', ' '], '_', $norm);
3419
                $canon = \array_key_exists($guess, $allowed) ? $guess : null;
3420
            }
3421
3422
            // Only produce buckets with canonical keys we support; unknown keys are ignored here
3423
            if (null !== $canon && \array_key_exists($canon, $allowed)) {
3424
                $out[$canon] = isset($out[$canon]) ? $merge($out[$canon], $bucket) : $bucket;
3425
            }
3426
        }
3427
3428
        // Hard safety net: if a "document" bucket existed before, ensure it remains.
3429
        if ($hadDocument && !isset($out['document'])) {
3430
            $out['document'] = (array) ($before['document'] ?? $before['Document'] ?? $before['documents'] ?? []);
3431
        }
3432
3433
        // Gentle ordering to keep things readable
3434
        $order = [
3435
            'announcement', 'document', 'link', 'link_category',
3436
            'forum', 'forum_category', 'forum_topic', 'forum_post',
3437
            'quiz', 'exercise_question',
3438
            'survey', 'survey_question',
3439
            'learnpath', 'tool_intro',
3440
            'work',
3441
        ];
3442
        $w = [];
3443
        foreach ($order as $i => $key) { $w[$key] = $i; }
3444
        uksort($out, static function ($a, $b) use ($w) {
3445
            $wa = $w[$a] ?? 9999;
3446
            $wb = $w[$b] ?? 9999;
3447
            return $wa <=> $wb ?: strcasecmp($a, $b);
3448
        });
3449
3450
        // Final assign (meta first, then normalized buckets)
3451
        $course->resources = $meta + $out;
3452
    }
3453
3454
    /**
3455
     * Read import_source without depending on filtered resources.
3456
     * Falls back to $course->info['__import_source'] if needed.
3457
     */
3458
    private function getImportSource(object $course): string
3459
    {
3460
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3461
        if ('' !== $src) {
3462
            return $src;
3463
        }
3464
3465
        // Fallbacks (defensive)
3466
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3467
    }
3468
3469
    /**
3470
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3471
     * Returns a structure intended for rendering/committing before the actual export.
3472
     */
3473
    private function buildCc13Preview(object $course): array
0 ignored issues
show
Unused Code introduced by
The method buildCc13Preview() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
3474
    {
3475
        $ims = [
3476
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3477
            'resources' => [
3478
                'webcontent' => [],
3479
            ],
3480
            'counts' => ['files' => 0, 'folders' => 0],
3481
            'defaultSelection' => [
3482
                'documents' => [],
3483
            ],
3484
        ];
3485
3486
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3487
        $docKey = null;
3488
3489
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3490
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3491
                $docKey = $cand;
3492
                break;
3493
            }
3494
        }
3495
        if (!$docKey) {
3496
            return $ims;
3497
        }
3498
3499
        foreach ($res[$docKey] as $iid => $wrap) {
3500
            if (!\is_object($wrap)) {
3501
                continue;
3502
            }
3503
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3504
3505
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3506
            if ('' === $rawPath) {
3507
                continue;
3508
            }
3509
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3510
3511
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3512
            $isDir = ($fileType === 'folder') || (substr($rawPath, -1) === '/');
3513
3514
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3515
            $ims['resources']['webcontent'][] = [
3516
                'id' => (int) $iid,
3517
                'cc_type' => 'webcontent',
3518
                'title' => $title !== '' ? $title : basename($rel),
3519
                'rel' => $rel,
3520
                'is_dir' => $isDir,
3521
                'would_be_manifest_entry' => !$isDir,
3522
            ];
3523
3524
            if (!$isDir) {
3525
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3526
                $ims['counts']['files']++;
3527
            } else {
3528
                $ims['counts']['folders']++;
3529
            }
3530
        }
3531
3532
        return $ims;
3533
    }
3534
3535
    /**
3536
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3537
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3538
     */
3539
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3540
    {
3541
        $out = [
3542
            'documents' => (array) ($normSel['documents'] ?? []),
3543
            'links'     => (array) ($normSel['links']     ?? []),
3544
            'forums'    => (array) ($normSel['forums']    ?? []),
3545
        ];
3546
3547
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3548
3549
        // Link categories → link IDs
3550
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3551
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3552
            $links   = $res['link'] ?? $res['Link'];
3553
            foreach ($links as $lid => $wrap) {
3554
                if (!\is_object($wrap)) { continue; }
3555
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3556
                $cid = (string) (int) ($e->category_id ?? 0);
3557
                if (isset($selCats[$cid])) { $out['links'][(string)$lid] = true; }
3558
            }
3559
        }
3560
3561
        // Forum categories → forum IDs
3562
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3563
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3564
            $forums  = $res['forum'] ?? $res['Forum'];
3565
            foreach ($forums as $fid => $wrap) {
3566
                if (!\is_object($wrap)) { continue; }
3567
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3568
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3569
                if (isset($selCats[$cid])) { $out['forums'][(string)$fid] = true; }
3570
            }
3571
        }
3572
3573
        return $out;
3574
    }
3575
3576
    /**
3577
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3578
     *
3579
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3580
     *
3581
     * @param array<int,array<string,mixed>> $selected
3582
     * @return string[]
3583
     */
3584
    private function inferToolsFromSelection(array $selected): array
3585
    {
3586
        $has = static fn(string $k): bool =>
3587
            !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3588
3589
        $want = [];
3590
3591
        // documents
3592
        if ($has('document')) {
3593
            $want[] = 'documents';
3594
        }
3595
3596
        // links (categories imply links too)
3597
        if ($has('link') || $has('link_category')) {
3598
            $want[] = 'links';
3599
        }
3600
3601
        // forums (any of the family implies forums)
3602
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3603
            $want[] = 'forums';
3604
        }
3605
3606
        // quizzes / questions
3607
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3608
            $want[] = 'quizzes';
3609
            $want[] = 'quiz_questions';
3610
        }
3611
3612
        // surveys / questions / invitations
3613
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3614
            $want[] = 'surveys';
3615
            $want[] = 'survey_questions';
3616
        }
3617
3618
        // learnpaths
3619
        if ($has('learnpath') || $has('learnpath_category')) {
3620
            $want[] = 'learnpaths';
3621
            $want[] = 'learnpath_category';
3622
        }
3623
3624
        // others
3625
        if ($has('work'))     { $want[] = 'works'; }
3626
        if ($has('glossary')) { $want[] = 'glossary'; }
3627
        if ($has('tool_intro')) { $want[] = 'tool_intro'; }
3628
3629
        // Dedup
3630
        return array_values(array_unique(array_filter($want)));
3631
    }
3632
3633
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3634
    {
3635
        $out = [];
3636
        foreach ($bucket as $id => $obj) {
3637
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3638
            $k1  = (string) $id;
3639
            $k2  = (string) ($ent->source_id ?? $obj->source_id ?? '');
3640
            if (isset($idsMap[$k1]) || ($k2 !== '' && isset($idsMap[$k2]))) {
3641
                $out[$id] = $obj;
3642
            }
3643
        }
3644
        return $out;
3645
    }
3646
3647
    private function bucketKeyCandidates(string $type): array
3648
    {
3649
        $t = $this->normalizeTypeKey($type);
3650
3651
        // Constants (string values) if defined
3652
        $RD  = \defined('RESOURCE_DOCUMENT')       ? (string) RESOURCE_DOCUMENT       : '';
3653
        $RL  = \defined('RESOURCE_LINK')           ? (string) RESOURCE_LINK           : '';
3654
        $RF  = \defined('RESOURCE_FORUM')          ? (string) RESOURCE_FORUM          : '';
3655
        $RFT = \defined('RESOURCE_FORUMTOPIC')     ? (string) RESOURCE_FORUMTOPIC     : '';
3656
        $RFP = \defined('RESOURCE_FORUMPOST')      ? (string) RESOURCE_FORUMPOST      : '';
3657
        $RQ  = \defined('RESOURCE_QUIZ')           ? (string) RESOURCE_QUIZ           : '';
3658
        $RQQ = \defined('RESOURCE_QUIZQUESTION')   ? (string) RESOURCE_QUIZQUESTION   : '';
3659
        $RS  = \defined('RESOURCE_SURVEY')         ? (string) RESOURCE_SURVEY         : '';
3660
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3661
3662
        $map = [
3663
            'document'         => ['document', 'Document', $RD],
3664
            'link'             => ['link', 'Link', $RL],
3665
            'link_category'    => ['link_category', 'Link_Category'],
3666
            'forum'            => ['forum', 'Forum', $RF],
3667
            'forum_category'   => ['forum_category', 'Forum_Category'],
3668
            'forum_topic'      => ['forum_topic', 'thread', $RFT],
3669
            'forum_post'       => ['forum_post', 'post', $RFP],
3670
            'quiz'             => ['quiz', 'Quiz', $RQ],
3671
            'exercise_question'=> ['Exercise_Question', 'exercise_question', $RQQ],
3672
            'survey'           => ['survey', 'Survey', $RS],
3673
            'survey_question'  => ['Survey_Question', 'survey_question', $RSQ],
3674
            'tool_intro'       => ['tool_intro', 'Tool introduction'],
3675
        ];
3676
3677
        $c = $map[$t] ?? [$t, ucfirst($t)];
3678
        return array_values(array_filter($c, static fn($x) => $x !== ''));
3679
    }
3680
3681
    private function findBucketKey(array $res, string $type): ?string
3682
    {
3683
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3684
        return $key !== null ? (string) $key : null;
3685
    }
3686
3687
    private function findBucket(array $res, string $type): array
3688
    {
3689
        $k = $this->findBucketKey($res, $type);
3690
        return ($k !== null && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3691
    }
3692
3693
    /** True if file extension suggests a Moodle backup. */
3694
    private function isMoodleByExt(string $path): bool
3695
    {
3696
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3697
        return in_array($ext, ['mbz','tgz','gz'], true);
3698
    }
3699
3700
    /** Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files. */
3701
    private function zipHasMoodleBackupXml(string $path): bool
3702
    {
3703
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3704
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3705
        if (!in_array($ext, ['zip','mbz'], true)) {
3706
            return false;
3707
        }
3708
        $zip = new \ZipArchive();
3709
        if (true !== ($err = $zip->open($path))) {
3710
            return false;
3711
        }
3712
        $idx = $zip->locateName('moodle_backup.xml', \ZipArchive::FL_NOCASE);
3713
        $zip->close();
3714
        return ($idx !== false);
3715
    }
3716
3717
    /** Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files. */
3718
    private function zipHasCourseInfoDat(string $path): bool
3719
    {
3720
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3721
        if (!in_array($ext, ['zip','mbz'], true)) {
3722
            return false;
3723
        }
3724
        $zip = new \ZipArchive();
3725
        if (true !== ($err = $zip->open($path))) {
3726
            return false;
3727
        }
3728
        // common locations
3729
        foreach (['course_info.dat','course/course_info.dat','backup/course_info.dat'] as $cand) {
3730
            $idx = $zip->locateName($cand, \ZipArchive::FL_NOCASE);
3731
            if ($idx !== false) { $zip->close(); return true; }
3732
        }
3733
        $zip->close();
3734
        return false;
3735
    }
3736
3737
    /**
3738
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3739
     * Throws RuntimeException on failure.
3740
     */
3741
    private function loadMoodleCourseOrFail(string $absPath): object
3742
    {
3743
        if (!class_exists(MoodleImport::class)) {
3744
            throw new \RuntimeException('MoodleImport class not available');
3745
        }
3746
        $importer = new MoodleImport(debug: $this->debug);
3747
3748
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
3749
            throw new \RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
3750
        }
3751
3752
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
3753
3754
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
3755
            throw new \RuntimeException('Moodle backup contains no importable resources');
3756
        }
3757
3758
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3759
        $course->resources['__meta']['import_source'] = 'moodle';
3760
3761
        return $course;
3762
    }
3763
3764
    /**
3765
     * Recursively sanitize an unserialized PHP graph:
3766
     * - Objects are cast to arrays, keys like "\0Class\0prop" become "prop"
3767
     * - Returns arrays/stdClass with only public-like keys
3768
     */
3769
    private function sanitizePhpGraph(mixed $value): mixed
3770
    {
3771
        if (\is_array($value)) {
3772
            $out = [];
3773
            foreach ($value as $k => $v) {
3774
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3775
                $out[$ck] = $this->sanitizePhpGraph($v);
3776
            }
3777
            return $out;
3778
        }
3779
3780
        if (\is_object($value)) {
3781
            $arr = (array) $value;
3782
            $clean = [];
3783
            foreach ($arr as $k => $v) {
3784
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3785
                $clean[$ck] = $this->sanitizePhpGraph($v);
3786
            }
3787
            return (object) $clean;
3788
        }
3789
3790
        return $value;
3791
    }
3792
}
3793