Passed
Push — master ( 35a819...0b3108 )
by
unknown
19:14 queued 09:06
created

getPhpUploadLimitBytes()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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

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

1167
                /** @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...
1168
1169
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
1170
            }
1171
1172
            // Execute import (creates Chamilo resources)
1173
            $importer = new Imscc13Import();
1174
            $importer->execute($extractDir);
1175
1176
            // Cleanup
1177
            Imscc13Import::rrmdir($extractDir);
1178
            @unlink($tmpZip);
1179
1180
            return $this->json([
1181
                'ok' => true,
1182
                'message' => 'CC 1.3 import completed successfully.',
1183
            ]);
1184
        } catch (Throwable $e) {
1185
            return $this->json([
1186
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
1187
            ], 500);
1188
        }
1189
    }
1190
1191
    #[Route(
1192
        '/import/{backupId}/diagnose',
1193
        name: 'import_diagnose',
1194
        requirements: ['backupId' => '.+'],
1195
        methods: ['GET']
1196
    )]
1197
    public function importDiagnose(int $node, string $backupId, Request $req): JsonResponse
1198
    {
1199
        $this->setDebugFromRequest($req);
1200
        $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]);
1201
1202
        try {
1203
            // Resolve absolute path of the uploaded/selected backup
1204
            $path = $this->resolveBackupPath($backupId);
1205
            if (!is_file($path)) {
1206
                return $this->json(['error' => 'Backup file not found', 'path' => $path], 404);
1207
            }
1208
1209
            // Read course_info.dat bytes from ZIP
1210
            $ci = $this->readCourseInfoFromZip($path);
1211
            if (empty($ci['ok'])) {
1212
                $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci);
1213
1214
                return $this->json([
1215
                    'meta' => [
1216
                        'backupId' => $backupId,
1217
                        'path' => $path,
1218
                    ],
1219
                    'zip' => [
1220
                        'error' => $ci['error'] ?? 'unknown error',
1221
                        'zip_list_sample' => $ci['zip_list_sample'] ?? [],
1222
                        'num_files' => $ci['num_files'] ?? null,
1223
                    ],
1224
                ], 200);
1225
            }
1226
1227
            $raw = (string) $ci['data'];
1228
            $size = (int) ($ci['size'] ?? \strlen($raw));
1229
            $md5 = md5($raw);
1230
1231
            // Detect & decode content
1232
            $probe = $this->decodeCourseInfo($raw);
1233
1234
            // Build a tiny scan snapshot (only keys, no grafo)
1235
            $scan = [
1236
                'has_graph' => false,
1237
                'resources_keys' => [],
1238
                'note' => 'No graph parsed',
1239
            ];
1240
1241
            if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) {
1242
                /** @var object $course */
1243
                $course = $probe['value'];
1244
                $scan['has_graph'] = true;
1245
                $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources))
1246
                    ? array_keys($course->resources)
1247
                    : [];
1248
                $scan['note'] = 'Parsed PHP serialized graph';
1249
            } elseif (!empty($probe['is_json']) && \is_array($probe['json_preview'])) {
1250
                $jp = $probe['json_preview'];
1251
                $scan['has_graph'] = true;
1252
                $scan['resources_keys'] = (isset($jp['resources']) && \is_array($jp['resources']))
1253
                    ? array_keys($jp['resources'])
1254
                    : [];
1255
                $scan['note'] = 'Parsed JSON document';
1256
            }
1257
1258
            $probeOut = $probe;
1259
            unset($probeOut['value'], $probeOut['decoded']);
1260
1261
            $out = [
1262
                'meta' => [
1263
                    'backupId' => $backupId,
1264
                    'path' => $path,
1265
                    'node' => $node,
1266
                ],
1267
                'zip' => [
1268
                    'name' => $ci['name'] ?? null,
1269
                    'index' => $ci['index'] ?? null,
1270
                ],
1271
                'course_info_dat' => [
1272
                    'size_bytes' => $size,
1273
                    'md5' => $md5,
1274
                ],
1275
                'probe' => $probeOut,
1276
                'scan' => $scan,
1277
            ];
1278
1279
            $this->logDebug('[importDiagnose] done', [
1280
                'encoding' => $probeOut['encoding'] ?? null,
1281
                'has_graph' => $scan['has_graph'],
1282
                'resources_keys' => $scan['resources_keys'],
1283
            ]);
1284
1285
            return $this->json($out);
1286
        } catch (Throwable $e) {
1287
            $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]);
1288
1289
            return $this->json([
1290
                'error' => 'Diagnosis failed: '.$e->getMessage(),
1291
            ], 500);
1292
        }
1293
    }
1294
1295
    /**
1296
     * Try to detect and decode course_info.dat content.
1297
     * Hardened: preprocess typed-prop numeric strings and register legacy aliases
1298
     * before attempting unserialize. Falls back to relaxed mode to avoid typed
1299
     * property crashes during diagnosis.
1300
     */
1301
    private function decodeCourseInfo(string $raw): array
1302
    {
1303
        $r = [
1304
            'encoding' => 'raw',
1305
            'decoded_len' => \strlen($raw),
1306
            'magic_hex' => bin2hex(substr($raw, 0, 8)),
1307
            'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
1308
            'steps' => [],
1309
            'decoded' => null,
1310
            'is_serialized' => false,
1311
            'is_json' => false,
1312
            'json_preview' => null,
1313
        ];
1314
1315
        $isJson = static function (string $s): bool {
1316
            $t = ltrim($s);
1317
1318
            return '' !== $t && ('{' === $t[0] || '[' === $t[0]);
1319
        };
1320
1321
        // Centralized tolerant unserialize with typed-props preprocessing
1322
        $tryUnserializeTolerant = function (string $s, string $label) use (&$r) {
1323
            $ok = false;
1324
            $val = null;
1325
            $err = null;
1326
            $relaxed = false;
1327
1328
            // Ensure legacy aliases and coerce numeric strings before unserialize
1329
            try {
1330
                CourseArchiver::ensureLegacyAliases();
1331
            } catch (Throwable) { /* ignore */
1332
            }
1333
1334
            try {
1335
                $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s);
1336
            } catch (Throwable) { /* ignore */
1337
            }
1338
1339
            // Strict mode
1340
            set_error_handler(static function (): void {});
1341
1342
            try {
1343
                $val = @unserialize($s, ['allowed_classes' => true]);
1344
                $ok = (false !== $val) || ('b:0;' === trim($s));
1345
            } catch (Throwable $e) {
1346
                $err = $e->getMessage();
1347
                $ok = false;
1348
            } finally {
1349
                restore_error_handler();
1350
            }
1351
            $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err];
1352
1353
            // Relaxed fallback (no class instantiation) + deincomplete to stdClass
1354
            if (!$ok) {
1355
                $err2 = null;
1356
                set_error_handler(static function (): void {});
1357
1358
                try {
1359
                    $tmp = @unserialize($s, ['allowed_classes' => false]);
1360
                    if (false !== $tmp || 'b:0;' === trim($s)) {
1361
                        $val = $this->deincomplete($tmp);
1362
                        $ok = true;
1363
                        $relaxed = true;
1364
                        $err = null;
1365
                    }
1366
                } catch (Throwable $e2) {
1367
                    $err2 = $e2->getMessage();
1368
                } finally {
1369
                    restore_error_handler();
1370
                }
1371
                $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2];
1372
            }
1373
1374
            if ($ok) {
1375
                $r['is_serialized'] = true;
1376
                $r['decoded'] = null; // keep payload minimal
1377
                $r['used_relaxed'] = $relaxed;
1378
1379
                return $val;
1380
            }
1381
1382
            return null;
1383
        };
1384
1385
        // 0) JSON as-is?
1386
        if ($isJson($raw)) {
1387
            $r['encoding'] = 'json';
1388
            $r['is_json'] = true;
1389
            $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1390
1391
            return $r;
1392
        }
1393
1394
        // Direct PHP serialize (strict then relaxed, after preprocessing)
1395
        if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) {
1396
            $r['encoding'] = 'php-serialize';
1397
1398
            return $r + ['value' => $u];
1399
        }
1400
1401
        // GZIP
1402
        if (0 === strncmp($raw, "\x1F\x8B", 2)) {
1403
            $dec = @gzdecode($raw);
1404
            $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec];
1405
            if (false !== $dec) {
1406
                if ($isJson($dec)) {
1407
                    $r['encoding'] = 'gzip+json';
1408
                    $r['is_json'] = true;
1409
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1410
1411
                    return $r;
1412
                }
1413
                if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) {
1414
                    $r['encoding'] = 'gzip+php-serialize';
1415
1416
                    return $r + ['value' => $u];
1417
                }
1418
            }
1419
        }
1420
1421
        // ZLIB/DEFLATE
1422
        $z2 = substr($raw, 0, 2);
1423
        if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) {
1424
            $dec = @gzuncompress($raw);
1425
            $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec];
1426
            if (false !== $dec) {
1427
                if ($isJson($dec)) {
1428
                    $r['encoding'] = 'zlib+json';
1429
                    $r['is_json'] = true;
1430
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1431
1432
                    return $r;
1433
                }
1434
                if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) {
1435
                    $r['encoding'] = 'zlib+php-serialize';
1436
1437
                    return $r + ['value' => $u];
1438
                }
1439
            }
1440
            $dec2 = @gzinflate($raw);
1441
            $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2];
1442
            if (false !== $dec2) {
1443
                if ($isJson($dec2)) {
1444
                    $r['encoding'] = 'deflate+json';
1445
                    $r['is_json'] = true;
1446
                    $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1447
1448
                    return $r;
1449
                }
1450
                if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) {
1451
                    $r['encoding'] = 'deflate+php-serialize';
1452
1453
                    return $r + ['value' => $u];
1454
                }
1455
            }
1456
        }
1457
1458
        // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...'))
1459
        if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) {
1460
            $dec = base64_decode($raw, true);
1461
            $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec];
1462
            if (false !== $dec) {
1463
                if ($isJson($dec)) {
1464
                    $r['encoding'] = 'base64(json)';
1465
                    $r['is_json'] = true;
1466
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1467
1468
                    return $r;
1469
                }
1470
                if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) {
1471
                    $r['encoding'] = 'base64(php-serialize)';
1472
1473
                    return $r + ['value' => $u];
1474
                }
1475
                // base64 + gzip nested
1476
                if (0 === strncmp($dec, "\x1F\x8B", 2)) {
1477
                    $dec2 = @gzdecode($dec);
1478
                    $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2];
1479
                    if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) {
1480
                        $r['encoding'] = 'base64(gzip+php-serialize)';
1481
1482
                        return $r + ['value' => $u];
1483
                    }
1484
                }
1485
            }
1486
        }
1487
1488
        // Nested ZIP?
1489
        if (0 === strncmp($raw, "PK\x03\x04", 4)) {
1490
            $r['encoding'] = 'nested-zip';
1491
        }
1492
1493
        return $r;
1494
    }
1495
1496
    /**
1497
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
1498
     * Also traverses arrays and objects (diagnostics-only).
1499
     */
1500
    private function deincomplete(mixed $v): mixed
1501
    {
1502
        if ($v instanceof __PHP_Incomplete_Class) {
1503
            $o = new stdClass();
1504
            foreach (get_object_vars($v) as $k => $vv) {
1505
                $o->{$k} = $this->deincomplete($vv);
1506
            }
1507
1508
            return $o;
1509
        }
1510
        if (\is_array($v)) {
1511
            foreach ($v as $k => $vv) {
1512
                $v[$k] = $this->deincomplete($vv);
1513
            }
1514
1515
            return $v;
1516
        }
1517
        if (\is_object($v)) {
1518
            foreach (get_object_vars($v) as $k => $vv) {
1519
                $v->{$k} = $this->deincomplete($vv);
1520
            }
1521
1522
            return $v;
1523
        }
1524
1525
        return $v;
1526
    }
1527
1528
    /**
1529
     * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive).
1530
     * Also tries common subpaths, e.g., "course/course_info.dat".
1531
     */
1532
    private function readCourseInfoFromZip(string $zipPath): array
1533
    {
1534
        $candidates = [
1535
            'course_info.dat',
1536
            'course/course_info.dat',
1537
            'backup/course_info.dat',
1538
        ];
1539
1540
        $zip = new ZipArchive();
1541
        if (true !== ($err = $zip->open($zipPath))) {
1542
            return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')'];
1543
        }
1544
1545
        // First: direct name lookup (case-insensitive)
1546
        $foundIdx = null;
1547
        $foundName = null;
1548
1549
        for ($i = 0; $i < $zip->numFiles; $i++) {
1550
            $st = $zip->statIndex($i);
1551
            if (!$st || !isset($st['name'])) {
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...
1552
                continue;
1553
            }
1554
            $name = (string) $st['name'];
1555
            $base = strtolower(basename($name));
1556
            if ('course_info.dat' === $base) {
1557
                $foundIdx = $i;
1558
                $foundName = $name;
1559
1560
                break;
1561
            }
1562
        }
1563
1564
        // Try specific candidate paths if direct scan failed
1565
        if (null === $foundIdx) {
1566
            foreach ($candidates as $cand) {
1567
                $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
1568
                if (false !== $idx) {
1569
                    $foundIdx = $idx;
1570
                    $foundName = $zip->getNameIndex($idx);
1571
1572
                    break;
1573
                }
1574
            }
1575
        }
1576
1577
        if (null === $foundIdx) {
1578
            // Build a short listing for debugging
1579
            $list = [];
1580
            $limit = min($zip->numFiles, 200);
1581
            for ($i = 0; $i < $limit; $i++) {
1582
                $n = $zip->getNameIndex($i);
1583
                if (false !== $n) {
1584
                    $list[] = $n;
1585
                }
1586
            }
1587
            $zip->close();
1588
1589
            return [
1590
                'ok' => false,
1591
                'error' => 'course_info.dat not found in archive',
1592
                'zip_list_sample' => $list,
1593
                'num_files' => $zip->numFiles,
1594
            ];
1595
        }
1596
1597
        $stat = $zip->statIndex($foundIdx);
1598
        $size = (int) ($stat['size'] ?? 0);
1599
        $fp = $zip->getStream($foundName);
1600
        if (!$fp) {
0 ignored issues
show
introduced by
$fp is of type resource, thus it always evaluated to false.
Loading history...
1601
            $zip->close();
1602
1603
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1604
        }
1605
1606
        $data = stream_get_contents($fp);
1607
        fclose($fp);
1608
        $zip->close();
1609
1610
        if (!\is_string($data)) {
1611
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1612
        }
1613
1614
        return [
1615
            'ok' => true,
1616
            'name' => $foundName,
1617
            'index' => $foundIdx,
1618
            'size' => $size,
1619
            'data' => $data,
1620
        ];
1621
    }
1622
1623
    /**
1624
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
1625
     * that reference the selected LearnPaths, taking the items from the full snapshot.
1626
     *
1627
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
1628
     */
1629
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
1630
    {
1631
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
1632
            return;
1633
        }
1634
1635
        $depTypes = [
1636
            'document', 'link', 'quiz', 'work', 'survey',
1637
            'Forum_Category', 'forum', 'thread', 'post',
1638
            'Exercise_Question', 'survey_question', 'Link_Category',
1639
        ];
1640
1641
        $need = [];
1642
        $addNeed = function (string $type, $id) use (&$need): void {
1643
            $t = (string) $type;
1644
            $i = is_numeric($id) ? (int) $id : (string) $id;
1645
            if ('' === $i || 0 === $i) {
1646
                return;
1647
            }
1648
            $need[$t] ??= [];
1649
            $need[$t][$i] = true;
1650
        };
1651
1652
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
1653
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
1654
1655
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
1656
                foreach ($lpWrap->linked_resources as $t => $ids) {
1657
                    if (!\is_array($ids)) {
1658
                        continue;
1659
                    }
1660
                    foreach ($ids as $rid) {
1661
                        $addNeed($t, $rid);
1662
                    }
1663
                }
1664
            }
1665
1666
            $items = [];
1667
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
1668
                $items = $lp->items;
1669
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
1670
                $items = $lpWrap->items;
1671
            }
1672
1673
            foreach ($items as $it) {
1674
                $ito = \is_object($it) ? $it : (object) $it;
1675
1676
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
1677
                    foreach ($ito->linked_resources as $t => $ids) {
1678
                        if (!\is_array($ids)) {
1679
                            continue;
1680
                        }
1681
                        foreach ($ids as $rid) {
1682
                            $addNeed($t, $rid);
1683
                        }
1684
                    }
1685
                }
1686
1687
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
1688
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
1689
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
1690
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
1691
                        if ('' !== $t) {
1692
                            $addNeed($t, $rid);
1693
                        }
1694
                    }
1695
                }
1696
1697
                if (!empty($ito->type) && isset($ito->ref)) {
1698
                    $addNeed((string) $ito->type, $ito->ref);
1699
                }
1700
            }
1701
        }
1702
1703
        if (empty($need)) {
1704
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
1705
            foreach ($core as $k) {
1706
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
1707
                    $course->resources[$k] ??= [];
1708
                    if (0 === \count($course->resources[$k])) {
1709
                        $course->resources[$k] = $snapshot[$k];
1710
                    }
1711
                }
1712
            }
1713
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
1714
                '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)),
1715
            ]);
1716
1717
            return;
1718
        }
1719
1720
        foreach ($need as $type => $idMap) {
1721
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
1722
                continue;
1723
            }
1724
1725
            $course->resources[$type] ??= [];
1726
1727
            foreach (array_keys($idMap) as $rid) {
1728
                $src = $snapshot[$type][$rid]
1729
                    ?? $snapshot[$type][(string) $rid]
1730
                    ?? null;
1731
1732
                if (!$src) {
1733
                    continue;
1734
                }
1735
1736
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
1737
                    $course->resources[$type][$rid] = $src;
1738
                }
1739
            }
1740
        }
1741
1742
        $this->logDebug('[LP-deps] hydrated', [
1743
            'types' => array_keys($need),
1744
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
1745
        ]);
1746
    }
1747
1748
    /**
1749
     * Build a Vue-friendly tree from legacy Course.
1750
     */
1751
    private function buildResourceTreeForVue(object $course): array
1752
    {
1753
        if ($this->debug) {
1754
            $this->logDebug('[buildResourceTreeForVue] start');
1755
        }
1756
1757
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1758
            ? $course->resources
1759
            : [];
1760
1761
        $legacyTitles = [];
1762
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1763
            /** @var array<string,string> $legacyTitles */
1764
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1765
        }
1766
        $fallbackTitles = $this->getDefaultTypeTitles();
1767
        $skipTypes = $this->getSkipTypeKeys();
1768
1769
        $tree = [];
1770
1771
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1772
            $docs = $resources['document'];
1773
1774
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1775
                $p = trim($rawPath, '/');
1776
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1777
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1778
1779
                // host
1780
                if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) {
1781
                    array_shift($parts);
1782
                }
1783
                // course-code
1784
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1785
                    array_shift($parts);
1786
                }
1787
1788
                $clean = implode('/', $parts);
1789
                if ('' === $clean && 'folder' !== $filetype) {
1790
                    $clean = $title;
1791
                }
1792
                if ('folder' === $filetype) {
1793
                    $clean = rtrim($clean, '/').'/';
1794
                }
1795
1796
                return $clean;
1797
            };
1798
1799
            $folderIdByPath = [];
1800
            foreach ($docs as $obj) {
1801
                if (!\is_object($obj)) {
1802
                    continue;
1803
                }
1804
                $ft = (string) ($obj->filetype ?? $obj->file_type ?? '');
1805
                if ('folder' !== $ft) {
1806
                    continue;
1807
                }
1808
                $rel = $normalize((string) $obj->path, (string) $obj->title, $ft);
1809
                $key = rtrim($rel, '/');
1810
                if ('' !== $key) {
1811
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1812
                }
1813
            }
1814
1815
            $docRoot = [];
1816
            $findChild = static function (array &$children, string $label): ?int {
1817
                foreach ($children as $i => $n) {
1818
                    if ((string) ($n['label'] ?? '') === $label) {
1819
                        return $i;
1820
                    }
1821
                }
1822
1823
                return null;
1824
            };
1825
1826
            foreach ($docs as $obj) {
1827
                if (!\is_object($obj)) {
1828
                    continue;
1829
                }
1830
1831
                $title = (string) $obj->title;
1832
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1833
                $rel = $normalize((string) $obj->path, $title, $filetype);
1834
                $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1835
1836
                $cursor = &$docRoot;
1837
                $soFar = '';
1838
                $total = \count($parts);
1839
1840
                for ($i = 0; $i < $total; $i++) {
1841
                    $seg = $parts[$i];
1842
                    $isLast = ($i === $total - 1);
1843
                    $isFolder = (!$isLast) || ('folder' === $filetype);
1844
1845
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1846
                    $label = $seg.($isFolder ? '/' : '');
1847
1848
                    $idx = $findChild($cursor, $label);
1849
                    if (null === $idx) {
1850
                        if ($isFolder) {
1851
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1852
                            $node = [
1853
                                'id' => $folderId ?? ('dir:'.$soFar),
1854
                                'label' => $label,
1855
                                'selectable' => true,
1856
                                'children' => [],
1857
                            ];
1858
                        } else {
1859
                            $node = [
1860
                                'id' => (int) $obj->source_id,
1861
                                'label' => $label,
1862
                                'selectable' => true,
1863
                            ];
1864
                        }
1865
                        $cursor[] = $node;
1866
                        $idx = \count($cursor) - 1;
1867
                    }
1868
1869
                    if ($isFolder) {
1870
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1871
                            $cursor[$idx]['children'] = [];
1872
                        }
1873
                        $cursor = &$cursor[$idx]['children'];
1874
                    }
1875
                }
1876
            }
1877
1878
            $sortTree = null;
1879
            $sortTree = function (array &$nodes) use (&$sortTree): void {
1880
                usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1881
                foreach ($nodes as &$n) {
1882
                    if (isset($n['children']) && \is_array($n['children'])) {
1883
                        $sortTree($n['children']);
1884
                    }
1885
                }
1886
            };
1887
            $sortTree($docRoot);
1888
1889
            $tree[] = [
1890
                'type' => 'document',
1891
                'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1892
                'children' => $docRoot,
1893
            ];
1894
1895
            $skipTypes['document'] = true;
1896
        }
1897
1898
        // Forums block
1899
        $hasForumData =
1900
            (!empty($resources['forum']) || !empty($resources['Forum']))
1901
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1902
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1903
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1904
1905
        if ($hasForumData) {
1906
            $tree[] = $this->buildForumTreeForVue(
1907
                $course,
1908
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1909
            );
1910
            $skipTypes['forum'] = true;
1911
            $skipTypes['forum_category'] = true;
1912
            $skipTypes['forum_topic'] = true;
1913
            $skipTypes['forum_post'] = true;
1914
            $skipTypes['thread'] = true;
1915
            $skipTypes['post'] = true;
1916
        }
1917
1918
        // Links block (Category → Link)
1919
        $hasLinkData =
1920
            (!empty($resources['link']) || !empty($resources['Link']))
1921
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1922
1923
        if ($hasLinkData) {
1924
            $tree[] = $this->buildLinkTreeForVue(
1925
                $course,
1926
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1927
            );
1928
            $skipTypes['link'] = true;
1929
            $skipTypes['link_category'] = true;
1930
        }
1931
1932
        foreach ($resources as $rawType => $items) {
1933
            if (!\is_array($items) || empty($items)) {
1934
                continue;
1935
            }
1936
            $typeKey = $this->normalizeTypeKey($rawType);
1937
            if (isset($skipTypes[$typeKey])) {
1938
                continue;
1939
            }
1940
1941
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1942
            $group = [
1943
                'type' => $typeKey,
1944
                'title' => (string) $groupTitle,
1945
                'items' => [],
1946
            ];
1947
1948
            if ('gradebook' === $typeKey) {
1949
                $group['items'][] = [
1950
                    'id' => 'all',
1951
                    'label' => 'Gradebook (all)',
1952
                    'extra' => new stdClass(),
1953
                    'selectable' => true,
1954
                ];
1955
                $tree[] = $group;
1956
1957
                continue;
1958
            }
1959
1960
            foreach ($items as $id => $obj) {
1961
                if (!\is_object($obj)) {
1962
                    continue;
1963
                }
1964
1965
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1966
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1967
                    continue;
1968
                }
1969
1970
                if (!$this->isSelectableItem($typeKey, $obj)) {
1971
                    continue;
1972
                }
1973
1974
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1975
1976
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1977
                    $label = $idKey;
1978
                }
1979
1980
                $extra = $this->buildExtra($typeKey, $obj);
1981
1982
                $group['items'][] = [
1983
                    'id' => $idKey,
1984
                    'label' => $label,
1985
                    'extra' => $extra ?: new stdClass(),
1986
                    'selectable' => true,
1987
                ];
1988
            }
1989
1990
            if (!empty($group['items'])) {
1991
                usort(
1992
                    $group['items'],
1993
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1994
                );
1995
                $tree[] = $group;
1996
            }
1997
        }
1998
1999
        // Preferred order
2000
        $preferredOrder = [
2001
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
2002
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
2003
        ];
2004
        usort($tree, static function ($a, $b) use ($preferredOrder) {
2005
            $ia = array_search($a['type'], $preferredOrder, true);
2006
            $ib = array_search($b['type'], $preferredOrder, true);
2007
            if (false !== $ia && false !== $ib) {
2008
                return $ia <=> $ib;
2009
            }
2010
            if (false !== $ia) {
2011
                return -1;
2012
            }
2013
            if (false !== $ib) {
2014
                return 1;
2015
            }
2016
2017
            return strcasecmp($a['title'], $b['title']);
2018
        });
2019
2020
        if ($this->debug) {
2021
            $this->logDebug(
2022
                '[buildResourceTreeForVue] end groups',
2023
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
2024
            );
2025
        }
2026
2027
        return $tree;
2028
    }
2029
2030
    /**
2031
     * Build forum tree (Category → Forum → Topic) for the UI.
2032
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
2033
     */
2034
    private function buildForumTreeForVue(object $course, string $groupTitle): array
2035
    {
2036
        $this->logDebug('[buildForumTreeForVue] start');
2037
2038
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2039
2040
        // Buckets (defensive: accept legacy casings / aliases)
2041
        $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
2042
        $forumRaw = $res['forum'] ?? $res['Forum'] ?? [];
2043
        $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []);
2044
        $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []);
2045
2046
        $this->logDebug('[buildForumTreeForVue] raw counts', [
2047
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2048
            'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0,
2049
            'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0,
2050
            'posts' => \is_array($postRaw) ? \count($postRaw) : 0,
2051
        ]);
2052
2053
        // Quick classifiers (defensive)
2054
        $isForum = function (object $o): bool {
2055
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2056
            if (isset($e->forum_title) && \is_string($e->forum_title)) {
2057
                return true;
2058
            }
2059
            if (isset($e->default_view) || isset($e->allow_anonymous)) {
2060
                return true;
2061
            }
2062
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) {
2063
                return true;
2064
            }
2065
2066
            return false;
2067
        };
2068
        $isTopic = function (object $o): bool {
2069
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2070
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) {
2071
                return true;
2072
            }
2073
            if (isset($e->forum_id) && !isset($e->forum_title)) {
2074
                return true;
2075
            }
2076
2077
            return false;
2078
        };
2079
        $getForumCategoryId = function (object $forum): int {
2080
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
2081
            $cid = (int) ($e->forum_category ?? 0);
2082
            if ($cid <= 0) {
2083
                $cid = (int) ($e->forum_category_id ?? 0);
2084
            }
2085
            if ($cid <= 0) {
2086
                $cid = (int) ($e->category_id ?? 0);
2087
            }
2088
2089
            return $cid;
2090
        };
2091
2092
        // Build categories
2093
        $cats = [];
2094
        foreach ($catRaw as $id => $obj) {
2095
            $id = (int) $id;
2096
            if ($id <= 0 || !\is_object($obj)) {
2097
                continue;
2098
            }
2099
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
2100
            $cats[$id] = [
2101
                'id' => $id,
2102
                'type' => 'forum_category',
2103
                'label' => ('' !== $label ? $label : 'Category #'.$id).'/',
2104
                'selectable' => true,
2105
                'items' => [],
2106
                'has_children' => false,
2107
                'item_count' => 0,
2108
                'extra' => ['filetype' => 'folder'],
2109
            ];
2110
        }
2111
        // Virtual "Uncategorized"
2112
        $uncatKey = -9999;
2113
        if (!isset($cats[$uncatKey])) {
2114
            $cats[$uncatKey] = [
2115
                'id' => $uncatKey,
2116
                'type' => 'forum_category',
2117
                'label' => 'Uncategorized/',
2118
                'selectable' => true,
2119
                'items' => [],
2120
                '_virtual' => true,
2121
                'has_children' => false,
2122
                'item_count' => 0,
2123
                'extra' => ['filetype' => 'folder'],
2124
            ];
2125
        }
2126
2127
        // Forums
2128
        $forums = [];
2129
        foreach ($forumRaw as $id => $obj) {
2130
            $id = (int) $id;
2131
            if ($id <= 0 || !\is_object($obj)) {
2132
                continue;
2133
            }
2134
            if (!$isForum($obj)) {
2135
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
2136
2137
                continue;
2138
            }
2139
            $forums[$id] = $this->objectEntity($obj);
2140
        }
2141
2142
        // Topics (+ post counts)
2143
        $topics = [];
2144
        $postCountByTopic = [];
2145
        foreach ($topicRaw as $id => $obj) {
2146
            $id = (int) $id;
2147
            if ($id <= 0 || !\is_object($obj)) {
2148
                continue;
2149
            }
2150
            if ($isForum($obj) && !$isTopic($obj)) {
2151
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2152
2153
                continue;
2154
            }
2155
            if (!$isTopic($obj)) {
2156
                continue;
2157
            }
2158
            $topics[$id] = $this->objectEntity($obj);
2159
        }
2160
        foreach ($postRaw as $id => $obj) {
2161
            $id = (int) $id;
2162
            if ($id <= 0 || !\is_object($obj)) {
2163
                continue;
2164
            }
2165
            $e = $this->objectEntity($obj);
2166
            $tid = (int) ($e->thread_id ?? 0);
2167
            if ($tid > 0) {
2168
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
2169
            }
2170
        }
2171
2172
        // Attach topics to forums and forums to categories
2173
        foreach ($forums as $fid => $f) {
2174
            $catId = $getForumCategoryId($f);
2175
            if (!isset($cats[$catId])) {
2176
                $catId = $uncatKey;
2177
            }
2178
2179
            $forumNode = [
2180
                'id' => $fid,
2181
                'type' => 'forum',
2182
                'label' => $this->resolveItemLabel('forum', $f, $fid),
2183
                'extra' => $this->buildExtra('forum', $f) ?: new stdClass(),
2184
                'selectable' => true,
2185
                'items' => [],
2186
                // UI hints
2187
                'has_children' => false,
2188
                'item_count' => 0,
2189
                'ui_depth' => 2,
2190
            ];
2191
2192
            foreach ($topics as $tid => $t) {
2193
                if ((int) ($t->forum_id ?? 0) !== $fid) {
2194
                    continue;
2195
                }
2196
2197
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2198
                $date = (string) ($t->thread_date ?? '');
2199
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
2200
2201
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2202
                $meta = [];
2203
                if ('' !== $author) {
2204
                    $meta[] = $author;
2205
                }
2206
                if ('' !== $date) {
2207
                    $meta[] = $date;
2208
                }
2209
                if ($meta) {
2210
                    $topicLabel .= ' ('.implode(', ', $meta).')';
2211
                }
2212
                if ($nPosts > 0) {
2213
                    $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's');
2214
                }
2215
2216
                $forumNode['items'][] = [
2217
                    'id' => $tid,
2218
                    'type' => 'forum_topic',
2219
                    'label' => $topicLabel,
2220
                    'extra' => new stdClass(),
2221
                    'selectable' => true,
2222
                    'ui_depth' => 3,
2223
                    'item_count' => 0,
2224
                ];
2225
            }
2226
2227
            if (!empty($forumNode['items'])) {
2228
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2229
                $forumNode['has_children'] = true;
2230
                $forumNode['item_count'] = \count($forumNode['items']);
2231
            }
2232
2233
            $cats[$catId]['items'][] = $forumNode;
2234
        }
2235
2236
        // Remove empty virtual category; sort forums inside each category
2237
        $catNodes = array_values(array_filter($cats, static function ($c) {
2238
            if (!empty($c['_virtual']) && empty($c['items'])) {
2239
                return false;
2240
            }
2241
2242
            return true;
2243
        }));
2244
2245
        // Flatten stray forums (defensive) and finalize UI hints
2246
        foreach ($catNodes as &$cat) {
2247
            if (!empty($cat['items'])) {
2248
                $lift = [];
2249
                foreach ($cat['items'] as &$forumNode) {
2250
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) {
2251
                        continue;
2252
                    }
2253
                    $keep = [];
2254
                    foreach ($forumNode['items'] as $child) {
2255
                        if (($child['type'] ?? '') === 'forum') {
2256
                            $lift[] = $child;
2257
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2258
                                'parent_forum_id' => $forumNode['id'] ?? null,
2259
                                'lifted_forum_id' => $child['id'] ?? null,
2260
                                'cat_id' => $cat['id'] ?? null,
2261
                            ]);
2262
                        } else {
2263
                            $keep[] = $child;
2264
                        }
2265
                    }
2266
                    $forumNode['items'] = $keep;
2267
                    $forumNode['has_children'] = !empty($keep);
2268
                    $forumNode['item_count'] = \count($keep);
2269
                }
2270
                unset($forumNode);
2271
2272
                foreach ($lift as $n) {
2273
                    $cat['items'][] = $n;
2274
                }
2275
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2276
            }
2277
2278
            // UI hints for category
2279
            $cat['has_children'] = !empty($cat['items']);
2280
            $cat['item_count'] = \count($cat['items'] ?? []);
2281
        }
2282
        unset($cat);
2283
2284
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2285
2286
        return [
2287
            'type' => 'forum',
2288
            'title' => $groupTitle,
2289
            'items' => $catNodes,
2290
        ];
2291
    }
2292
2293
    /**
2294
     * Normalize a raw type to a lowercase key.
2295
     */
2296
    private function normalizeTypeKey(int|string $raw): string
2297
    {
2298
        if (\is_int($raw)) {
2299
            return (string) $raw;
2300
        }
2301
2302
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
2303
2304
        $map = [
2305
            'forum_category' => 'forum_category',
2306
            'forumtopic' => 'forum_topic',
2307
            'forum_topic' => 'forum_topic',
2308
            'forum_post' => 'forum_post',
2309
            'thread' => 'forum_topic',
2310
            'post' => 'forum_post',
2311
            'exercise_question' => 'exercise_question',
2312
            'surveyquestion' => 'survey_question',
2313
            'surveyinvitation' => 'survey_invitation',
2314
            'survey' => 'survey',
2315
            'link_category' => 'link_category',
2316
            'coursecopylearnpath' => 'learnpath',
2317
            'coursecopytestcategory' => 'test_category',
2318
            'coursedescription' => 'course_description',
2319
            'session_course' => 'session_course',
2320
            'gradebookbackup' => 'gradebook',
2321
            'scormdocument' => 'scorm',
2322
            'tool/introduction' => 'tool_intro',
2323
            'tool_introduction' => 'tool_intro',
2324
        ];
2325
2326
        return $map[$s] ?? $s;
2327
    }
2328
2329
    /**
2330
     * Keys to skip as top-level groups in UI.
2331
     *
2332
     * @return array<string,bool>
2333
     */
2334
    private function getSkipTypeKeys(): array
2335
    {
2336
        return [
2337
            'forum_category' => true,
2338
            'forum_topic' => true,
2339
            'forum_post' => true,
2340
            'thread' => true,
2341
            'post' => true,
2342
            'exercise_question' => true,
2343
            'survey_question' => true,
2344
            'survey_invitation' => true,
2345
            'session_course' => true,
2346
            'scorm' => true,
2347
            'asset' => true,
2348
            'link_category' => true,
2349
        ];
2350
    }
2351
2352
    /**
2353
     * Default labels for groups.
2354
     *
2355
     * @return array<string,string>
2356
     */
2357
    private function getDefaultTypeTitles(): array
2358
    {
2359
        return [
2360
            'announcement' => 'Announcements',
2361
            'document' => 'Documents',
2362
            'glossary' => 'Glossaries',
2363
            'calendar_event' => 'Calendar events',
2364
            'event' => 'Calendar events',
2365
            'link' => 'Links',
2366
            'course_description' => 'Course descriptions',
2367
            'learnpath' => 'Parcours',
2368
            'learnpath_category' => 'Learning path categories',
2369
            'forum' => 'Forums',
2370
            'forum_category' => 'Forum categories',
2371
            'quiz' => 'Exercices',
2372
            'test_category' => 'Test categories',
2373
            'wiki' => 'Wikis',
2374
            'thematic' => 'Thematics',
2375
            'attendance' => 'Attendances',
2376
            'work' => 'Works',
2377
            'session_course' => 'Session courses',
2378
            'gradebook' => 'Gradebook',
2379
            'scorm' => 'SCORM packages',
2380
            'survey' => 'Surveys',
2381
            'survey_question' => 'Survey questions',
2382
            'survey_invitation' => 'Survey invitations',
2383
            'asset' => 'Assets',
2384
            'tool_intro' => 'Tool introductions',
2385
        ];
2386
    }
2387
2388
    /**
2389
     * Decide if an item is selectable (UI).
2390
     */
2391
    private function isSelectableItem(string $type, object $obj): bool
2392
    {
2393
        if ('document' === $type) {
2394
            return true;
2395
        }
2396
2397
        return true;
2398
    }
2399
2400
    /**
2401
     * Resolve label for an item with fallbacks.
2402
     */
2403
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2404
    {
2405
        $entity = $this->objectEntity($obj);
2406
2407
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2408
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2409
                return trim((string) $entity->{$k});
2410
            }
2411
        }
2412
2413
        if (isset($obj->params) && \is_array($obj->params)) {
2414
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2415
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2416
                    return (string) $obj->params[$k];
2417
                }
2418
            }
2419
        }
2420
2421
        switch ($type) {
2422
            case 'document':
2423
                // 1) ruta cruda tal como viene del backup/DB
2424
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2425
                if ('' !== $raw) {
2426
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
2427
                    $rel = ltrim($raw, '/');
2428
                    $rel = preg_replace('~^document/?~', '', $rel);
2429
2430
                    // 3) carpeta ⇒ que termine con "/"
2431
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2432
                    if ('folder' === $fileType) {
2433
                        $rel = rtrim($rel, '/').'/';
2434
                    }
2435
2436
                    // 4) si la ruta quedó vacía, usa basename como último recurso
2437
                    return '' !== $rel ? $rel : basename($raw);
2438
                }
2439
2440
                // fallback: título o nombre de archivo
2441
                if (!empty($obj->title)) {
2442
                    return (string) $obj->title;
2443
                }
2444
2445
                break;
2446
2447
            case 'course_description':
2448
                if (!empty($obj->title)) {
2449
                    return (string) $obj->title;
2450
                }
2451
                $t = (int) ($obj->description_type ?? 0);
2452
                $names = [
2453
                    1 => 'Description',
2454
                    2 => 'Objectives',
2455
                    3 => 'Topics',
2456
                    4 => 'Methodology',
2457
                    5 => 'Course material',
2458
                    6 => 'Resources',
2459
                    7 => 'Assessment',
2460
                    8 => 'Custom',
2461
                ];
2462
2463
                return $names[$t] ?? ('#'.$fallbackId);
2464
2465
            case 'announcement':
2466
                if (!empty($obj->title)) {
2467
                    return (string) $obj->title;
2468
                }
2469
2470
                break;
2471
2472
            case 'forum':
2473
                if (!empty($entity->forum_title)) {
2474
                    return (string) $entity->forum_title;
2475
                }
2476
2477
                break;
2478
2479
            case 'forum_category':
2480
                if (!empty($entity->cat_title)) {
2481
                    return (string) $entity->cat_title;
2482
                }
2483
2484
                break;
2485
2486
            case 'link':
2487
                if (!empty($obj->title)) {
2488
                    return (string) $obj->title;
2489
                }
2490
                if (!empty($obj->url)) {
2491
                    return (string) $obj->url;
2492
                }
2493
2494
                break;
2495
2496
            case 'survey':
2497
                if (!empty($obj->title)) {
2498
                    return trim((string) $obj->title);
2499
                }
2500
2501
                break;
2502
2503
            case 'learnpath':
2504
                if (!empty($obj->name)) {
2505
                    return (string) $obj->name;
2506
                }
2507
2508
                break;
2509
2510
            case 'thematic':
2511
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2512
                    return (string) $obj->params['title'];
2513
                }
2514
2515
                break;
2516
2517
            case 'quiz':
2518
                if (!empty($entity->title)) {
2519
                    return (string) $entity->title;
2520
                }
2521
2522
                break;
2523
2524
            case 'forum_topic':
2525
                if (!empty($entity->thread_title)) {
2526
                    return (string) $entity->thread_title;
2527
                }
2528
2529
                break;
2530
        }
2531
2532
        return '#'.$fallbackId;
2533
    }
2534
2535
    /**
2536
     * Extract wrapped entity (->obj) or the object itself.
2537
     */
2538
    private function objectEntity(object $resource): object
2539
    {
2540
        if (isset($resource->obj) && \is_object($resource->obj)) {
2541
            return $resource->obj;
2542
        }
2543
2544
        return $resource;
2545
    }
2546
2547
    /**
2548
     * Extra payload per item for UI (optional).
2549
     */
2550
    private function buildExtra(string $type, object $obj): array
2551
    {
2552
        $extra = [];
2553
2554
        $get = static function (object $o, string $k, $default = null) {
2555
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2556
        };
2557
2558
        switch ($type) {
2559
            case 'document':
2560
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2561
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2562
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2563
2564
                break;
2565
2566
            case 'link':
2567
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2568
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2569
2570
                break;
2571
2572
            case 'forum':
2573
                $entity = $this->objectEntity($obj);
2574
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2575
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2576
2577
                break;
2578
2579
            case 'learnpath':
2580
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2581
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2582
                    return [
2583
                        'id' => (int) ($i['id'] ?? 0),
2584
                        'title' => (string) ($i['title'] ?? ''),
2585
                        'type' => (string) ($i['item_type'] ?? ''),
2586
                        'path' => (string) ($i['path'] ?? ''),
2587
                    ];
2588
                }, $obj->items) : [];
2589
2590
                break;
2591
2592
            case 'thematic':
2593
                if (isset($obj->params) && \is_array($obj->params)) {
2594
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2595
                }
2596
2597
                break;
2598
2599
            case 'quiz':
2600
                $entity = $this->objectEntity($obj);
2601
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2602
                    ? array_map('intval', $entity->question_ids)
2603
                    : [];
2604
2605
                break;
2606
2607
            case 'survey':
2608
                $entity = $this->objectEntity($obj);
2609
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2610
                    ? array_map('intval', $entity->question_ids)
2611
                    : [];
2612
2613
                break;
2614
        }
2615
2616
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2617
    }
2618
2619
    // --------------------------------------------------------------------------------
2620
    // Selection filtering (used by partial restore)
2621
    // --------------------------------------------------------------------------------
2622
2623
    /**
2624
     * Get first existing key from candidates.
2625
     */
2626
    private function firstExistingKey(array $orig, array $candidates): ?string
2627
    {
2628
        foreach ($candidates as $k) {
2629
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2630
                return $k;
2631
            }
2632
        }
2633
2634
        return null;
2635
    }
2636
2637
    /**
2638
     * Filter legacy Course by UI selections (and pull dependencies).
2639
     *
2640
     * @param array $selected [type => [id => true]]
2641
     */
2642
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2643
    {
2644
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2645
        $selected = array_filter($selected, 'is_array');
2646
        unset($selected['undefined']);
2647
2648
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2649
2650
        if (empty($course->resources) || !\is_array($course->resources)) {
2651
            $this->logDebug('[filterSelection] course has no resources');
2652
2653
            return $course;
2654
        }
2655
2656
        /** @var array<string,mixed> $orig */
2657
        $orig = $course->resources;
2658
2659
        // Preserve meta buckets (keys that start with "__")
2660
        $__metaBuckets = [];
2661
        foreach ($orig as $k => $v) {
2662
            if (\is_string($k) && str_starts_with($k, '__')) {
2663
                $__metaBuckets[$k] = $v;
2664
            }
2665
        }
2666
2667
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2668
2669
        // ---------- Forums flow ----------
2670
        if (!empty($selected['forum'])) {
2671
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2672
            if (!empty($selForums)) {
2673
                // tolerant lookups
2674
                $forums = $this->findBucket($orig, 'forum');
2675
                $threads = $this->findBucket($orig, 'forum_topic');
2676
                $posts = $this->findBucket($orig, 'forum_post');
2677
2678
                $catsToKeep = [];
2679
2680
                foreach ($forums as $fid => $f) {
2681
                    if (!isset($selForums[(string) $fid])) {
2682
                        continue;
2683
                    }
2684
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2685
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2686
                    if ($cid > 0) {
2687
                        $catsToKeep[$cid] = true;
2688
                    }
2689
                }
2690
2691
                $threadToKeep = [];
2692
                foreach ($threads as $tid => $t) {
2693
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2694
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2695
                        $threadToKeep[(int) $tid] = true;
2696
                    }
2697
                }
2698
2699
                $postToKeep = [];
2700
                foreach ($posts as $pid => $p) {
2701
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2702
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2703
                        $postToKeep[(int) $pid] = true;
2704
                    }
2705
                }
2706
2707
                $out = [];
2708
                foreach ($selected as $type => $ids) {
2709
                    if (!\is_array($ids) || empty($ids)) {
2710
                        continue;
2711
                    }
2712
                    $bucket = $this->findBucket($orig, (string) $type);
2713
                    $key = $this->findBucketKey($orig, (string) $type);
2714
                    if (null !== $key && !empty($bucket)) {
2715
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2716
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2717
                    }
2718
                }
2719
2720
                $forumCat = $this->findBucket($orig, 'forum_category');
2721
                $forumBucket = $this->findBucket($orig, 'forum');
2722
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2723
                $postBucket = $this->findBucket($orig, 'forum_post');
2724
2725
                if (!empty($forumCat) && !empty($catsToKeep)) {
2726
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2727
                        array_intersect_key(
2728
                            $forumCat,
2729
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2730
                        );
2731
                }
2732
2733
                if (!empty($forumBucket)) {
2734
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2735
                        array_intersect_key($forumBucket, $selForums);
2736
                }
2737
                if (!empty($threadBucket)) {
2738
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2739
                        array_intersect_key(
2740
                            $threadBucket,
2741
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2742
                        );
2743
                }
2744
                if (!empty($postBucket)) {
2745
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2746
                        array_intersect_key(
2747
                            $postBucket,
2748
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2749
                        );
2750
                }
2751
2752
                // If we have forums but no Forum_Category (edge), keep original categories
2753
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2754
                    $out['Forum_Category'] = $forumCat;
2755
                }
2756
2757
                $out = array_filter($out);
2758
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2759
2760
                $this->logDebug('[filterSelection] end (forums)', [
2761
                    'kept_types' => array_keys($course->resources),
2762
                    'forum_counts' => [
2763
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2764
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2765
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2766
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2767
                    ],
2768
                ]);
2769
2770
                return $course;
2771
            }
2772
        }
2773
2774
        // ---------- Generic + quiz/survey/gradebook ----------
2775
        $keep = [];
2776
        foreach ($selected as $type => $ids) {
2777
            if (!\is_array($ids) || empty($ids)) {
2778
                continue;
2779
            }
2780
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2781
            if (null === $legacyKey) {
2782
                continue;
2783
            }
2784
            $bucket = $orig[$legacyKey] ?? [];
2785
            if (!empty($bucket) && \is_array($bucket)) {
2786
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2787
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2788
            }
2789
        }
2790
2791
        // Gradebook
2792
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2793
        if ($gbKey && !empty($selected['gradebook'])) {
2794
            $gbBucket = $getBucket($orig, $gbKey);
2795
            if (!empty($gbBucket)) {
2796
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2797
                $firstItem = reset($gbBucket);
2798
2799
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2800
                    $keep[$gbKey] = $gbBucket;
2801
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2802
                } else {
2803
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2804
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2805
                }
2806
            }
2807
        }
2808
2809
        // Quizzes -> questions (+ images)
2810
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
2811
        if ($quizKey && !empty($keep[$quizKey])) {
2812
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2813
            if ($questionKey) {
2814
                $qids = [];
2815
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2816
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2817
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2818
                        foreach ($q->question_ids as $sid) {
2819
                            $qids[(string) $sid] = true;
2820
                        }
2821
                    }
2822
                }
2823
                if (!empty($qids)) {
2824
                    $questionBucket = $getBucket($orig, $questionKey);
2825
                    $selQ = array_intersect_key($questionBucket, $qids);
2826
                    if (!empty($selQ)) {
2827
                        $keep[$questionKey] = $selQ;
2828
2829
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2830
                        if ($docKey) {
2831
                            $docBucket = $getBucket($orig, $docKey);
2832
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2833
                            if (!empty($imageQuizBucket)) {
2834
                                $needed = [];
2835
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2836
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2837
                                    $pic = (string) ($q->picture ?? '');
2838
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2839
                                        $needed[$pic] = true;
2840
                                    }
2841
                                }
2842
                                if (!empty($needed)) {
2843
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2844
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2845
                                }
2846
                            }
2847
                        }
2848
                    }
2849
                }
2850
            } else {
2851
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2852
            }
2853
        }
2854
2855
        // Surveys -> questions (+ invitations)
2856
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
2857
        if ($surveyKey && !empty($keep[$surveyKey])) {
2858
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2859
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2860
2861
            if ($surveyQuestionKey) {
2862
                $neededQids = [];
2863
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2864
2865
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2866
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2867
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2868
                        foreach ($s->question_ids as $qid) {
2869
                            $neededQids[(string) $qid] = true;
2870
                        }
2871
                    }
2872
                }
2873
                if (empty($neededQids)) {
2874
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2875
                    foreach ($surveyQBucket as $qid => $qWrap) {
2876
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2877
                        $qSurveyId = (string) ($q->survey_id ?? '');
2878
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2879
                            $neededQids[(string) $qid] = true;
2880
                        }
2881
                    }
2882
                }
2883
                if (!empty($neededQids)) {
2884
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2885
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2886
                }
2887
            } else {
2888
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2889
            }
2890
2891
            if ($surveyInvitationKey) {
2892
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2893
                if (!empty($invBucket)) {
2894
                    $neededInv = [];
2895
                    foreach ($invBucket as $iid => $invWrap) {
2896
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2897
                        $sid = (string) ($inv->survey_id ?? '');
2898
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2899
                            $neededInv[(string) $iid] = true;
2900
                        }
2901
                    }
2902
                    if (!empty($neededInv)) {
2903
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2904
                    }
2905
                }
2906
            }
2907
        }
2908
2909
        // Documents: add parent folders for selected files
2910
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2911
        if ($docKey && !empty($keep[$docKey])) {
2912
            $docBucket = $getBucket($orig, $docKey);
2913
2914
            $foldersByRel = [];
2915
            foreach ($docBucket as $fid => $res) {
2916
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2917
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2918
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2919
                if (!$isFolder) {
2920
                    continue;
2921
                }
2922
2923
                $p = (string) ($e->path ?? '');
2924
                if ('' === $p) {
2925
                    continue;
2926
                }
2927
2928
                $frel = '/'.ltrim(substr($p, 8), '/');
2929
                $frel = rtrim($frel, '/').'/';
2930
                if ('//' !== $frel) {
2931
                    $foldersByRel[$frel] = $fid;
2932
                }
2933
            }
2934
2935
            $needFolderIds = [];
2936
            foreach ($keep[$docKey] as $id => $res) {
2937
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2938
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2939
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2940
                if ($isFolder) {
2941
                    continue;
2942
                }
2943
2944
                $p = (string) ($e->path ?? '');
2945
                if ('' === $p) {
2946
                    continue;
2947
                }
2948
2949
                $rel = '/'.ltrim(substr($p, 8), '/');
2950
                $dir = rtrim(\dirname($rel), '/');
2951
                if ('' === $dir) {
2952
                    continue;
2953
                }
2954
2955
                $acc = '';
2956
                foreach (array_filter(explode('/', $dir)) as $seg) {
2957
                    $acc .= '/'.$seg;
2958
                    $accKey = rtrim($acc, '/').'/';
2959
                    if (isset($foldersByRel[$accKey])) {
2960
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2961
                    }
2962
                }
2963
            }
2964
            if (!empty($needFolderIds)) {
2965
                $added = array_intersect_key($docBucket, $needFolderIds);
2966
                $keep[$docKey] += $added;
2967
            }
2968
        }
2969
2970
        // Links -> pull categories used by the selected links
2971
        $lnkKey = $this->firstExistingKey($orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2972
        if ($lnkKey && !empty($keep[$lnkKey])) {
2973
            $catIdsUsed = [];
2974
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2975
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2976
                $cid = (int) ($L->category_id ?? 0);
2977
                if ($cid > 0) {
2978
                    $catIdsUsed[(string) $cid] = true;
2979
                }
2980
            }
2981
2982
            $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
2983
            if ($catKey && !empty($catIdsUsed)) {
2984
                $catBucket = $getBucket($orig, $catKey);
2985
                if (!empty($catBucket)) {
2986
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2987
                    $keep[$catKey] = $subset;
2988
                    $keep['link_category'] = $subset; // mirror for convenience
2989
                }
2990
            }
2991
        }
2992
2993
        $keep = array_filter($keep);
2994
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
2995
2996
        $this->logDebug('[filterSelection] non-forum flow end', [
2997
            'selected_types' => array_keys($selected),
2998
            'orig_types' => array_keys($orig),
2999
            'kept_types' => array_keys($course->resources ?? []),
3000
        ]);
3001
3002
        return $course;
3003
    }
3004
3005
    /**
3006
     * Map UI options (1/2/3) to legacy file policy.
3007
     */
3008
    private function mapSameNameOption(int $opt): int
3009
    {
3010
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
3011
3012
        if (!\defined('FILE_SKIP')) {
3013
            \define('FILE_SKIP', 1);
3014
        }
3015
        if (!\defined('FILE_RENAME')) {
3016
            \define('FILE_RENAME', 2);
3017
        }
3018
        if (!\defined('FILE_OVERWRITE')) {
3019
            \define('FILE_OVERWRITE', 3);
3020
        }
3021
3022
        return match ($opt) {
3023
            1 => FILE_SKIP,
3024
            3 => FILE_OVERWRITE,
3025
            default => FILE_RENAME,
3026
        };
3027
    }
3028
3029
    /**
3030
     * Set debug mode from Request (query/header).
3031
     */
3032
    private function setDebugFromRequest(?Request $req): void
3033
    {
3034
        if (!$req) {
3035
            return;
3036
        }
3037
        // Query param wins
3038
        if ($req->query->has('debug')) {
3039
            $this->debug = $req->query->getBoolean('debug');
3040
3041
            return;
3042
        }
3043
        // Fallback to header
3044
        $hdr = $req->headers->get('X-Debug');
3045
        if (null !== $hdr) {
3046
            $val = trim((string) $hdr);
3047
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
3048
        }
3049
    }
3050
3051
    /**
3052
     * Debug logger with stage + compact JSON payload.
3053
     */
3054
    private function logDebug(string $stage, mixed $payload = null): void
3055
    {
3056
        if (!$this->debug) {
3057
            return;
3058
        }
3059
        $prefix = 'COURSE_DEBUG';
3060
        if (null === $payload) {
3061
            error_log("$prefix: $stage");
3062
3063
            return;
3064
        }
3065
        // Safe/short json
3066
        $json = null;
3067
3068
        try {
3069
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
3070
            if (null !== $json && \strlen($json) > 8000) {
3071
                $json = substr($json, 0, 8000).'…(truncated)';
3072
            }
3073
        } catch (Throwable $e) {
3074
            $json = '[payload_json_error: '.$e->getMessage().']';
3075
        }
3076
        error_log("$prefix: $stage -> $json");
3077
    }
3078
3079
    /**
3080
     * Snapshot of resources bag for quick inspection.
3081
     */
3082
    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...
3083
    {
3084
        $out = [];
3085
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3086
        $i = 0;
3087
        foreach ($res as $type => $bag) {
3088
            if ($i++ >= $maxTypes) {
3089
                $out['__notice'] = 'types truncated';
3090
3091
                break;
3092
            }
3093
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
3094
            if (\is_array($bag)) {
3095
                $j = 0;
3096
                foreach ($bag as $id => $obj) {
3097
                    if ($j++ >= $maxItemsPerType) {
3098
                        $snap['sample'][] = ['__notice' => 'truncated'];
3099
3100
                        break;
3101
                    }
3102
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3103
                    $snap['sample'][] = [
3104
                        'id' => (string) $id,
3105
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
3106
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
3107
                    ];
3108
                }
3109
            }
3110
            $out[(string) $type] = $snap;
3111
        }
3112
3113
        return $out;
3114
    }
3115
3116
    /**
3117
     * Snapshot of forum-family counters.
3118
     */
3119
    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...
3120
    {
3121
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
3122
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
3123
3124
        return [
3125
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
3126
            'forum' => $get('forum', 'Forum'),
3127
            'thread' => $get('thread', 'forum_topic'),
3128
            'post' => $get('post', 'forum_post'),
3129
        ];
3130
    }
3131
3132
    /**
3133
     * Builds the selection map [type => [id => true]] from high-level types.
3134
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
3135
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
3136
     *
3137
     * @param object   $course        Legacy Course with already hydrated resources
3138
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
3139
     *
3140
     * @return array<string, array<int|string, bool>>
3141
     */
3142
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
3143
    {
3144
        $selectedTypes = array_map(
3145
            fn ($t) => $this->normalizeTypeKey((string) $t),
3146
            $selectedTypes
3147
        );
3148
3149
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3150
3151
        $coreDeps = [
3152
            'document', 'link', 'quiz', 'work', 'survey',
3153
            'Forum_Category', 'forum', 'thread', 'post',
3154
            'exercise_question', 'survey_question', 'link_category',
3155
        ];
3156
3157
        $presentKeys = array_fill_keys(array_map(
3158
            fn ($k) => $this->normalizeTypeKey((string) $k),
3159
            array_keys($res)
3160
        ), true);
3161
3162
        $out = [];
3163
3164
        $addBucket = function (string $typeKey) use (&$out, $res): void {
3165
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
3166
                return;
3167
            }
3168
            $ids = [];
3169
            foreach ($res[$typeKey] as $id => $_) {
3170
                $ids[(string) $id] = true;
3171
            }
3172
            if ($ids) {
3173
                $out[$typeKey] = $ids;
3174
            }
3175
        };
3176
3177
        foreach ($selectedTypes as $t) {
3178
            $addBucket($t);
3179
3180
            if ('learnpath' === $t) {
3181
                foreach ($coreDeps as $depRaw) {
3182
                    $dep = $this->normalizeTypeKey($depRaw);
3183
                    if (isset($presentKeys[$dep])) {
3184
                        $addBucket($dep);
3185
                    }
3186
                }
3187
            }
3188
        }
3189
3190
        $this->logDebug('[buildSelectionFromTypes] built', [
3191
            'selectedTypes' => $selectedTypes,
3192
            'kept_types' => array_keys($out),
3193
        ]);
3194
3195
        return $out;
3196
    }
3197
3198
    /**
3199
     * Build link tree (Category → Link) for the UI.
3200
     * Categories are not selectable; links are leaves (item_count = 0).
3201
     */
3202
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3203
    {
3204
        $this->logDebug('[buildLinkTreeForVue] start');
3205
3206
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3207
        $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? [];
3208
        $linkRaw = $res['link'] ?? $res['Link'] ?? [];
3209
3210
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3211
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3212
            'links' => \is_array($linkRaw) ? \count($linkRaw) : 0,
3213
        ]);
3214
3215
        $cats = [];
3216
        foreach ($catRaw as $id => $obj) {
3217
            $id = (int) $id;
3218
            if ($id <= 0 || !\is_object($obj)) {
3219
                continue;
3220
            }
3221
            $e = $this->objectEntity($obj);
3222
            $label = $this->resolveItemLabel('link_category', $e, $id);
3223
            $cats[$id] = [
3224
                'id' => $id,
3225
                'type' => 'link_category',
3226
                'label' => (('' !== $label ? $label : ('Category #'.$id)).'/'),
3227
                'selectable' => true,
3228
                'items' => [],
3229
                'has_children' => false,
3230
                'item_count' => 0,
3231
                'extra' => ['filetype' => 'folder'],
3232
            ];
3233
        }
3234
3235
        // Virtual "Uncategorized"
3236
        $uncatKey = -9999;
3237
        if (!isset($cats[$uncatKey])) {
3238
            $cats[$uncatKey] = [
3239
                'id' => $uncatKey,
3240
                'type' => 'link_category',
3241
                'label' => 'Uncategorized/',
3242
                'selectable' => true,
3243
                'items' => [],
3244
                '_virtual' => true,
3245
                'has_children' => false,
3246
                'item_count' => 0,
3247
                'extra' => ['filetype' => 'folder'],
3248
            ];
3249
        }
3250
3251
        // Assign links to categories
3252
        foreach ($linkRaw as $id => $obj) {
3253
            $id = (int) $id;
3254
            if ($id <= 0 || !\is_object($obj)) {
3255
                continue;
3256
            }
3257
            $e = $this->objectEntity($obj);
3258
3259
            $cid = (int) ($e->category_id ?? 0);
3260
            if (!isset($cats[$cid])) {
3261
                $cid = $uncatKey;
3262
            }
3263
3264
            $cats[$cid]['items'][] = [
3265
                'id' => $id,
3266
                'type' => 'link',
3267
                'label' => $this->resolveItemLabel('link', $e, $id),
3268
                'extra' => $this->buildExtra('link', $e) ?: new stdClass(),
3269
                'selectable' => true,
3270
                'item_count' => 0,
3271
            ];
3272
        }
3273
3274
        // Drop empty virtual category, sort, and finalize UI hints
3275
        $catNodes = array_values(array_filter($cats, static function ($c) {
3276
            if (!empty($c['_virtual']) && empty($c['items'])) {
3277
                return false;
3278
            }
3279
3280
            return true;
3281
        }));
3282
3283
        foreach ($catNodes as &$c) {
3284
            if (!empty($c['items'])) {
3285
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3286
            }
3287
            $c['has_children'] = !empty($c['items']);
3288
            $c['item_count'] = \count($c['items'] ?? []);
3289
        }
3290
        unset($c);
3291
3292
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3293
3294
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3295
3296
        return [
3297
            'type' => 'link',
3298
            'title' => $groupTitle,
3299
            'items' => $catNodes,
3300
        ];
3301
    }
3302
3303
    /**
3304
     * Leaves only the items selected by the UI in $course->resources.
3305
     * Expects $selected with the following form:
3306
     * [
3307
     * "documents" => ["123" => true, "124" => true],
3308
     * "links" => ["7" => true],
3309
     * "quiz" => ["45" => true],
3310
     * ...
3311
     * ].
3312
     */
3313
    private function filterCourseResources(object $course, array $selected): void
3314
    {
3315
        if (!isset($course->resources) || !\is_array($course->resources)) {
3316
            return;
3317
        }
3318
3319
        $typeMap = [
3320
            'documents' => RESOURCE_DOCUMENT,
3321
            'links' => RESOURCE_LINK,
3322
            'quizzes' => RESOURCE_QUIZ,
3323
            'quiz' => RESOURCE_QUIZ,
3324
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3325
            'surveys' => RESOURCE_SURVEY,
3326
            'survey' => RESOURCE_SURVEY,
3327
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3328
            'announcements' => RESOURCE_ANNOUNCEMENT,
3329
            'events' => RESOURCE_EVENT,
3330
            'course_description' => RESOURCE_COURSEDESCRIPTION,
3331
            'glossary' => RESOURCE_GLOSSARY,
3332
            'wiki' => RESOURCE_WIKI,
3333
            'thematic' => RESOURCE_THEMATIC,
3334
            'attendance' => RESOURCE_ATTENDANCE,
3335
            'works' => RESOURCE_WORK,
3336
            'gradebook' => RESOURCE_GRADEBOOK,
3337
            'learnpaths' => RESOURCE_LEARNPATH,
3338
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3339
            'tool_intro' => RESOURCE_TOOL_INTRO,
3340
            'forums' => RESOURCE_FORUM,
3341
            'forum' => RESOURCE_FORUM,
3342
            'forum_topic' => RESOURCE_FORUMTOPIC,
3343
            'forum_post' => RESOURCE_FORUMPOST,
3344
        ];
3345
3346
        $allowed = [];
3347
        foreach ($selected as $k => $idsMap) {
3348
            $key = $typeMap[$k] ?? $k;
3349
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3350
        }
3351
3352
        foreach ($course->resources as $rtype => $bucket) {
3353
            if (!isset($allowed[$rtype])) {
3354
                continue;
3355
            }
3356
            $keep = $allowed[$rtype];
3357
            $filtered = [];
3358
            foreach ((array) $bucket as $id => $obj) {
3359
                $iid = (int) ($obj->source_id ?? $id);
3360
                if (isset($keep[$iid])) {
3361
                    $filtered[$id] = $obj;
3362
                }
3363
            }
3364
            $course->resources[$rtype] = $filtered;
3365
        }
3366
    }
3367
3368
    /**
3369
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3370
     */
3371
    private function resolveBackupPath(string $backupId): string
3372
    {
3373
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3374
        $baseReal = realpath($base) ?: $base;
3375
3376
        $file = basename($backupId);
3377
        $path = $baseReal.DIRECTORY_SEPARATOR.$file;
3378
3379
        $real = realpath($path);
3380
3381
        if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) {
3382
            return $real;
3383
        }
3384
3385
        return $path;
3386
    }
3387
3388
    /**
3389
     * Load a legacy Course object from any backup:
3390
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3391
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3392
     *
3393
     * IMPORTANT:
3394
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3395
     * - Tries Moodle only when the package looks like Moodle.
3396
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3397
     */
3398
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3399
    {
3400
        $path = $this->resolveBackupPath($backupId);
3401
3402
        $force = strtolower($force);
3403
        if ('dat' === $force || 'chamilo' === $force) {
3404
            $looksMoodle = false;
3405
            $preferChamilo = true;
3406
        } elseif ('moodle' === $force) {
3407
            $looksMoodle = true;
3408
            $preferChamilo = false;
3409
        } else {
3410
            $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3411
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3412
        }
3413
3414
        if ($preferChamilo || !$looksMoodle) {
3415
            CourseArchiver::setDebug($this->debug);
3416
3417
            try {
3418
                $course = CourseArchiver::readCourse($backupId, false);
3419
                if (\is_object($course)) {
3420
                    // … (resto igual)
3421
                    if (!isset($course->resources) || !\is_array($course->resources)) {
3422
                        $course->resources = [];
3423
                    }
3424
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3425
                    $course->resources['__meta']['import_source'] = 'chamilo';
3426
3427
                    return $course;
3428
                }
3429
            } catch (Throwable $e) {
3430
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3431
            }
3432
3433
            $zipPath = $this->resolveBackupPath($backupId);
3434
            $ci = $this->readCourseInfoFromZip($zipPath);
3435
            if (empty($ci['ok'])) {
3436
                if ($looksMoodle) {
3437
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3438
3439
                    return $this->loadMoodleCourseOrFail($path);
3440
                }
3441
3442
                throw new RuntimeException('course_info.dat not found in backup');
3443
            }
3444
3445
            $raw = (string) $ci['data'];
3446
            $payload = base64_decode($raw, true);
3447
            if (false === $payload) {
3448
                $payload = $raw;
3449
            }
3450
3451
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3452
            CourseArchiver::ensureLegacyAliases();
3453
3454
            set_error_handler(static function (): void {});
3455
3456
            try {
3457
                if (class_exists(UnserializeApi::class)) {
3458
                    $c = UnserializeApi::unserialize('course', $payload);
3459
                } else {
3460
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3461
                }
3462
            } finally {
3463
                restore_error_handler();
3464
            }
3465
3466
            if (!\is_object($c ?? null)) {
3467
                if ($looksMoodle) {
3468
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3469
3470
                    return $this->loadMoodleCourseOrFail($path);
3471
                }
3472
3473
                throw new RuntimeException('Could not unserialize course (fallback)');
3474
            }
3475
3476
            if (!isset($c->resources) || !\is_array($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...
3477
                $c->resources = [];
3478
            }
3479
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3480
            $c->resources['__meta']['import_source'] = 'chamilo';
3481
3482
            return $c;
3483
        }
3484
3485
        // Moodle path
3486
        if ($looksMoodle) {
3487
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3488
3489
            return $this->loadMoodleCourseOrFail($path);
3490
        }
3491
3492
        throw new RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3493
    }
3494
3495
    /**
3496
     * Normalize resource buckets to the exact keys supported by CourseRestorer.
3497
     * Only the canonical keys below are produced; common aliases are mapped.
3498
     * - Never drop data: merge buckets; keep __meta as-is.
3499
     * - Make sure "document" survives if it existed before.
3500
     */
3501
    private function normalizeBucketsForRestorer(object $course): void
3502
    {
3503
        if (!isset($course->resources) || !\is_array($course->resources)) {
3504
            return;
3505
        }
3506
3507
        // Split meta buckets
3508
        $all = $course->resources;
3509
        $meta = [];
3510
        foreach ($all as $k => $v) {
3511
            if (\is_string($k) && str_starts_with($k, '__')) {
3512
                $meta[$k] = $v;
3513
                unset($all[$k]);
3514
            }
3515
        }
3516
3517
        // Start from current
3518
        $out = $all;
3519
3520
        // merge array buckets preserving numeric/string ids
3521
        $merge = static function (array $dst, array $src): array {
3522
            foreach ($src as $id => $obj) {
3523
                if (!\array_key_exists($id, $dst)) {
3524
                    $dst[$id] = $obj;
3525
                }
3526
            }
3527
3528
            return $dst;
3529
        };
3530
3531
        // safe alias map (input -> canonical). Extend only if needed.
3532
        $aliases = [
3533
            // documents
3534
            'documents' => 'document',
3535
            'Document' => 'document',
3536
            'document ' => 'document',
3537
3538
            // tool intro
3539
            'tool introduction' => 'tool_intro',
3540
            'tool_introduction' => 'tool_intro',
3541
            'tool/introduction' => 'tool_intro',
3542
            'tool intro' => 'tool_intro',
3543
            'Tool introduction' => 'tool_intro',
3544
3545
            // forums
3546
            'forums' => 'forum',
3547
            'Forum' => 'forum',
3548
            'Forum_Category' => 'forum_category',
3549
            'forumcategory' => 'forum_category',
3550
            'thread' => 'forum_topic',
3551
            'Thread' => 'forum_topic',
3552
            'forumtopic' => 'forum_topic',
3553
            'post' => 'forum_post',
3554
            'Post' => 'forum_post',
3555
            'forumpost' => 'forum_post',
3556
3557
            // links
3558
            'links' => 'link',
3559
            'link category' => 'link_category',
3560
3561
            // quiz + questions
3562
            'Exercise_Question' => 'exercise_question',
3563
            'exercisequestion' => 'exercise_question',
3564
3565
            // surveys
3566
            'surveys' => 'survey',
3567
            'surveyquestion' => 'survey_question',
3568
3569
            // announcements
3570
            'announcements' => 'announcement',
3571
            'Announcements' => 'announcement',
3572
        ];
3573
3574
        // Normalize keys (case/spacing) and apply alias merges
3575
        foreach ($all as $rawKey => $_bucket) {
3576
            if (!\is_array($_bucket)) {
3577
                continue; // defensive
3578
            }
3579
            $k = (string) $rawKey;
3580
            $norm = strtolower(trim(strtr($k, ['\\' => '/', '-' => '_'])));
3581
            $norm2 = str_replace('/', '_', $norm);
3582
3583
            $canonical = null;
3584
            if (isset($aliases[$norm])) {
3585
                $canonical = $aliases[$norm];
3586
            } elseif (isset($aliases[$norm2])) {
3587
                $canonical = $aliases[$norm2];
3588
            }
3589
3590
            if ($canonical && $canonical !== $rawKey) {
3591
                // Merge into canonical and drop the alias key
3592
                $out[$canonical] = isset($out[$canonical]) && \is_array($out[$canonical])
3593
                    ? $merge($out[$canonical], $_bucket)
3594
                    : $_bucket;
3595
                unset($out[$rawKey]);
3596
            }
3597
            // else: leave as-is (pass-through)
3598
        }
3599
3600
        // Safety: if there was any docs bucket under an alias, ensure 'document' is present.
3601
        if (!isset($out['document'])) {
3602
            if (isset($all['documents']) && \is_array($all['documents'])) {
3603
                $out['document'] = $all['documents'];
3604
            } elseif (isset($all['Document']) && \is_array($all['Document'])) {
3605
                $out['document'] = $all['Document'];
3606
            }
3607
        }
3608
3609
        // Gentle ordering for readability only (does not affect presence)
3610
        $order = [
3611
            'announcement', 'document', 'link', 'link_category',
3612
            'forum', 'forum_category', 'forum_topic', 'forum_post',
3613
            'quiz', 'exercise_question',
3614
            'survey', 'survey_question',
3615
            'learnpath', 'tool_intro',
3616
            'work',
3617
        ];
3618
        $w = [];
3619
        foreach ($order as $i => $key) {
3620
            $w[$key] = $i;
3621
        }
3622
        uksort($out, static function ($a, $b) use ($w) {
3623
            $wa = $w[$a] ?? 9999;
3624
            $wb = $w[$b] ?? 9999;
3625
3626
            return $wa <=> $wb ?: strcasecmp((string) $a, (string) $b);
3627
        });
3628
3629
        // Final assign: meta first, then normalized buckets
3630
        $course->resources = $meta + $out;
3631
3632
        // Debug trace to verify we didn't lose keys
3633
        $this->logDebug('[normalizeBucketsForRestorer] final keys', array_keys((array) $course->resources));
3634
    }
3635
3636
    /**
3637
     * Read import_source without depending on filtered resources.
3638
     * Falls back to $course->info['__import_source'] if needed.
3639
     */
3640
    private function getImportSource(object $course): string
3641
    {
3642
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3643
        if ('' !== $src) {
3644
            return $src;
3645
        }
3646
3647
        // Fallbacks (defensive)
3648
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3649
    }
3650
3651
    /**
3652
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3653
     * Returns a structure intended for rendering/committing before the actual export.
3654
     */
3655
    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...
3656
    {
3657
        $ims = [
3658
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3659
            'resources' => [
3660
                'webcontent' => [],
3661
            ],
3662
            'counts' => ['files' => 0, 'folders' => 0],
3663
            'defaultSelection' => [
3664
                'documents' => [],
3665
            ],
3666
        ];
3667
3668
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3669
        $docKey = null;
3670
3671
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3672
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3673
                $docKey = $cand;
3674
3675
                break;
3676
            }
3677
        }
3678
        if (!$docKey) {
3679
            return $ims;
3680
        }
3681
3682
        foreach ($res[$docKey] as $iid => $wrap) {
3683
            if (!\is_object($wrap)) {
3684
                continue;
3685
            }
3686
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3687
3688
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3689
            if ('' === $rawPath) {
3690
                continue;
3691
            }
3692
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3693
3694
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3695
            $isDir = ('folder' === $fileType) || ('/' === substr($rawPath, -1));
3696
3697
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3698
            $ims['resources']['webcontent'][] = [
3699
                'id' => (int) $iid,
3700
                'cc_type' => 'webcontent',
3701
                'title' => '' !== $title ? $title : basename($rel),
3702
                'rel' => $rel,
3703
                'is_dir' => $isDir,
3704
                'would_be_manifest_entry' => !$isDir,
3705
            ];
3706
3707
            if (!$isDir) {
3708
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3709
                $ims['counts']['files']++;
3710
            } else {
3711
                $ims['counts']['folders']++;
3712
            }
3713
        }
3714
3715
        return $ims;
3716
    }
3717
3718
    /**
3719
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3720
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3721
     *
3722
     * @return array<string, array<string, bool>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array<string, bool> at position 9 could not be parsed: Expected '>' at position 9, but found '>'.
Loading history...
3723
     */
3724
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3725
    {
3726
        $out = [
3727
            'documents' => (array) ($normSel['documents'] ?? []),
3728
            'links' => (array) ($normSel['links'] ?? []),
3729
            'forums' => (array) ($normSel['forums'] ?? []),
3730
        ];
3731
3732
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3733
3734
        // Link categories → link IDs
3735
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3736
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3737
            $links = $res['link'] ?? $res['Link'];
3738
            foreach ($links as $lid => $wrap) {
3739
                if (!\is_object($wrap)) {
3740
                    continue;
3741
                }
3742
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3743
                $cid = (string) (int) ($e->category_id ?? 0);
3744
                if (isset($selCats[$cid])) {
3745
                    $out['links'][(string) $lid] = true;
3746
                }
3747
            }
3748
        }
3749
3750
        // Forum categories → forum IDs
3751
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3752
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3753
            $forums = $res['forum'] ?? $res['Forum'];
3754
            foreach ($forums as $fid => $wrap) {
3755
                if (!\is_object($wrap)) {
3756
                    continue;
3757
                }
3758
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3759
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3760
                if (isset($selCats[$cid])) {
3761
                    $out['forums'][(string) $fid] = true;
3762
                }
3763
            }
3764
        }
3765
3766
        return $out;
3767
    }
3768
3769
    /**
3770
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3771
     *
3772
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3773
     *
3774
     * @param array<int,array<string,mixed>> $selected
3775
     *
3776
     * @return string[]
3777
     */
3778
    private function inferToolsFromSelection(array $selected): array
3779
    {
3780
        $has = static fn (string $k): bool => !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3781
3782
        $want = [];
3783
3784
        // documents
3785
        if ($has('document')) {
3786
            $want[] = 'documents';
3787
        }
3788
3789
        // links (categories imply links too)
3790
        if ($has('link') || $has('link_category')) {
3791
            $want[] = 'links';
3792
        }
3793
3794
        // forums (any of the family implies forums)
3795
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3796
            $want[] = 'forums';
3797
        }
3798
3799
        // quizzes / questions
3800
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3801
            $want[] = 'quizzes';
3802
            $want[] = 'quiz_questions';
3803
        }
3804
3805
        // surveys / questions / invitations
3806
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3807
            $want[] = 'surveys';
3808
            $want[] = 'survey_questions';
3809
        }
3810
3811
        // learnpaths
3812
        if ($has('learnpath') || $has('learnpath_category')) {
3813
            $want[] = 'learnpaths';
3814
            $want[] = 'learnpath_category';
3815
        }
3816
3817
        // others
3818
        if ($has('work')) {
3819
            $want[] = 'works';
3820
        }
3821
        if ($has('glossary')) {
3822
            $want[] = 'glossary';
3823
        }
3824
        if ($has('tool_intro')) {
3825
            $want[] = 'tool_intro';
3826
        }
3827
        if ($has('course_descriptions') || $has('course_description')) {
3828
            $tools[] = 'course_descriptions';
0 ignored issues
show
Comprehensibility Best Practice introduced by
$tools was never initialized. Although not strictly required by PHP, it is generally a good practice to add $tools = array(); before regardless.
Loading history...
3829
        }
3830
3831
        // Dedup
3832
        return array_values(array_unique(array_filter($want)));
3833
    }
3834
3835
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3836
    {
3837
        $out = [];
3838
        foreach ($bucket as $id => $obj) {
3839
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3840
            $k1 = (string) $id;
3841
            $k2 = (string) ($ent->source_id ?? $obj->source_id ?? '');
3842
            if (isset($idsMap[$k1]) || ('' !== $k2 && isset($idsMap[$k2]))) {
3843
                $out[$id] = $obj;
3844
            }
3845
        }
3846
3847
        return $out;
3848
    }
3849
3850
    private function bucketKeyCandidates(string $type): array
3851
    {
3852
        $t = $this->normalizeTypeKey($type);
3853
3854
        // Constants (string values) if defined
3855
        $RD = \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : '';
3856
        $RL = \defined('RESOURCE_LINK') ? (string) RESOURCE_LINK : '';
3857
        $RF = \defined('RESOURCE_FORUM') ? (string) RESOURCE_FORUM : '';
3858
        $RFT = \defined('RESOURCE_FORUMTOPIC') ? (string) RESOURCE_FORUMTOPIC : '';
3859
        $RFP = \defined('RESOURCE_FORUMPOST') ? (string) RESOURCE_FORUMPOST : '';
3860
        $RQ = \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : '';
3861
        $RQQ = \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : '';
3862
        $RS = \defined('RESOURCE_SURVEY') ? (string) RESOURCE_SURVEY : '';
3863
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3864
3865
        $map = [
3866
            'document' => ['document', 'Document', $RD],
3867
            'link' => ['link', 'Link', $RL],
3868
            'link_category' => ['link_category', 'Link_Category'],
3869
            'forum' => ['forum', 'Forum', $RF],
3870
            'forum_category' => ['forum_category', 'Forum_Category'],
3871
            'forum_topic' => ['forum_topic', 'thread', $RFT],
3872
            'forum_post' => ['forum_post', 'post', $RFP],
3873
            'quiz' => ['quiz', 'Quiz', $RQ],
3874
            'exercise_question' => ['Exercise_Question', 'exercise_question', $RQQ],
3875
            'survey' => ['survey', 'Survey', $RS],
3876
            'survey_question' => ['Survey_Question', 'survey_question', $RSQ],
3877
            'tool_intro' => ['tool_intro', 'Tool introduction'],
3878
        ];
3879
3880
        $c = $map[$t] ?? [$t, ucfirst($t)];
3881
3882
        return array_values(array_filter($c, static fn ($x) => '' !== $x));
3883
    }
3884
3885
    private function findBucketKey(array $res, string $type): ?string
3886
    {
3887
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3888
3889
        return null !== $key ? (string) $key : null;
3890
    }
3891
3892
    private function findBucket(array $res, string $type): array
3893
    {
3894
        $k = $this->findBucketKey($res, $type);
3895
3896
        return (null !== $k && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3897
    }
3898
3899
    /**
3900
     * True if file extension suggests a Moodle backup.
3901
     */
3902
    private function isMoodleByExt(string $path): bool
3903
    {
3904
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3905
3906
        return \in_array($ext, ['mbz', 'tgz', 'gz'], true);
3907
    }
3908
3909
    /**
3910
     * Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files.
3911
     */
3912
    private function zipHasMoodleBackupXml(string $path): bool
3913
    {
3914
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3915
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3916
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3917
            return false;
3918
        }
3919
        $zip = new ZipArchive();
3920
        if (true !== ($err = $zip->open($path))) {
3921
            return false;
3922
        }
3923
        $idx = $zip->locateName('moodle_backup.xml', ZipArchive::FL_NOCASE);
3924
        $zip->close();
3925
3926
        return false !== $idx;
3927
    }
3928
3929
    /**
3930
     * Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files.
3931
     */
3932
    private function zipHasCourseInfoDat(string $path): bool
3933
    {
3934
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3935
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3936
            return false;
3937
        }
3938
        $zip = new ZipArchive();
3939
        if (true !== ($err = $zip->open($path))) {
3940
            return false;
3941
        }
3942
        // common locations
3943
        foreach (['course_info.dat', 'course/course_info.dat', 'backup/course_info.dat'] as $cand) {
3944
            $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
3945
            if (false !== $idx) {
3946
                $zip->close();
3947
3948
                return true;
3949
            }
3950
        }
3951
        $zip->close();
3952
3953
        return false;
3954
    }
3955
3956
    /**
3957
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3958
     * Throws RuntimeException on failure.
3959
     */
3960
    private function loadMoodleCourseOrFail(string $absPath): object
3961
    {
3962
        if (!class_exists(MoodleImport::class)) {
3963
            throw new RuntimeException('MoodleImport class not available');
3964
        }
3965
        $importer = new MoodleImport(debug: $this->debug);
3966
3967
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
3968
            throw new RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
3969
        }
3970
3971
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
3972
3973
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
3974
            throw new RuntimeException('Moodle backup contains no importable resources');
3975
        }
3976
3977
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3978
        $course->resources['__meta']['import_source'] = 'moodle';
3979
3980
        return $course;
3981
    }
3982
3983
    /**
3984
     * Recursively sanitize an unserialized PHP graph:
3985
     * - Objects are cast to arrays, keys like "\0Class\0prop" become "prop"
3986
     * - Returns arrays/stdClass with only public-like keys
3987
     */
3988
    private function sanitizePhpGraph(mixed $value): mixed
3989
    {
3990
        if (\is_array($value)) {
3991
            $out = [];
3992
            foreach ($value as $k => $v) {
3993
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3994
                $out[$ck] = $this->sanitizePhpGraph($v);
3995
            }
3996
3997
            return $out;
3998
        }
3999
4000
        if (\is_object($value)) {
4001
            $arr = (array) $value;
4002
            $clean = [];
4003
            foreach ($arr as $k => $v) {
4004
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
4005
                $clean[$ck] = $this->sanitizePhpGraph($v);
4006
            }
4007
4008
            return (object) $clean;
4009
        }
4010
4011
        return $value;
4012
    }
4013
4014
    private static function getPhpUploadLimitBytes(): int
4015
    {
4016
        // Use the strictest PHP limit (min of upload_max_filesize and post_max_size).
4017
        $limits = [];
4018
4019
        $u = ini_get('upload_max_filesize');
4020
        if (is_string($u)) {
4021
            $limits[] = self::iniSizeToBytes($u);
4022
        }
4023
4024
        $p = ini_get('post_max_size');
4025
        if (is_string($p)) {
4026
            $limits[] = self::iniSizeToBytes($p);
4027
        }
4028
4029
        // Keep only positive limits. If both are 0, treat as "no limit".
4030
        $limits = array_values(array_filter($limits, static fn (int $v): bool => $v > 0));
4031
4032
        return empty($limits) ? 0 : min($limits);
4033
    }
4034
4035
    private static function iniSizeToBytes(string $val): int
4036
    {
4037
        // Parses values like "2G", "512M", "900K", "1048576".
4038
        $val = trim($val);
4039
        if ($val === '') {
4040
            return 0;
4041
        }
4042
        if ($val === '0') {
4043
            return 0; // "no limit" for upload/post.
4044
        }
4045
4046
        if (!preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*([kmgt])?b?$/i', $val, $m)) {
4047
            return (int) $val;
4048
        }
4049
4050
        $num = (float) $m[1];
4051
        $unit = strtolower((string) ($m[2] ?? ''));
4052
4053
        switch ($unit) {
4054
            case 't':
4055
                $num *= 1024;
4056
            // no break
4057
            case 'g':
4058
                $num *= 1024;
4059
            // no break
4060
            case 'm':
4061
                $num *= 1024;
4062
            // no break
4063
            case 'k':
4064
                $num *= 1024;
4065
                break;
4066
        }
4067
4068
        return (int) round($num);
4069
    }
4070
}
4071