Passed
Pull Request — master (#6935)
by
unknown
08:44
created

CourseMaintenanceController::importDiagnose()   C

Complexity

Conditions 13
Paths 76

Size

Total Lines 101
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 68
c 0
b 0
f 0
nc 76
nop 3
dl 0
loc 101
rs 5.9915

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

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

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

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

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

Loading history...
1451
            $name = (string) $st['name'];
1452
            $base = strtolower(basename($name));
1453
            if ($base === 'course_info.dat') {
1454
                $foundIdx = $i;
1455
                $foundName = $name;
1456
                break;
1457
            }
1458
        }
1459
1460
        // Try specific candidate paths if direct scan failed
1461
        if ($foundIdx === null) {
1462
            foreach ($candidates as $cand) {
1463
                $idx = $zip->locateName($cand, \ZipArchive::FL_NOCASE);
1464
                if ($idx !== false) {
1465
                    $foundIdx = $idx;
1466
                    $foundName = $zip->getNameIndex($idx);
1467
                    break;
1468
                }
1469
            }
1470
        }
1471
1472
        if ($foundIdx === null) {
1473
            // Build a short listing for debugging
1474
            $list = [];
1475
            $limit = min($zip->numFiles, 200);
1476
            for ($i = 0; $i < $limit; $i++) {
1477
                $n = $zip->getNameIndex($i);
1478
                if ($n !== false) { $list[] = $n; }
1479
            }
1480
            $zip->close();
1481
1482
            return [
1483
                'ok' => false,
1484
                'error' => 'course_info.dat not found in archive',
1485
                'zip_list_sample' => $list,
1486
                'num_files' => $zip->numFiles,
1487
            ];
1488
        }
1489
1490
        $stat = $zip->statIndex($foundIdx);
1491
        $size = (int) ($stat['size'] ?? 0);
1492
        $fp   = $zip->getStream($foundName);
1493
        if (!$fp) {
0 ignored issues
show
introduced by
$fp is of type resource, thus it always evaluated to false.
Loading history...
1494
            $zip->close();
1495
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1496
        }
1497
1498
        $data = stream_get_contents($fp);
1499
        fclose($fp);
1500
        $zip->close();
1501
1502
        if (!is_string($data)) {
1503
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1504
        }
1505
1506
        return [
1507
            'ok'        => true,
1508
            'name'      => $foundName,
1509
            'index'     => $foundIdx,
1510
            'size'      => $size,
1511
            'data'      => $data,
1512
        ];
1513
    }
1514
1515
    /**
1516
     * Copies the dependencies (document, link, quiz, etc.) to $course->resources
1517
     * that reference the selected LearnPaths, taking the items from the full snapshot.
1518
     *
1519
     * It doesn't break anything if something is missing or comes in a different format: it's defensive.
1520
     */
1521
    private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void
1522
    {
1523
        if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) {
1524
            return;
1525
        }
1526
1527
        $depTypes = [
1528
            'document', 'link', 'quiz', 'work', 'survey',
1529
            'Forum_Category', 'forum', 'thread', 'post',
1530
            'Exercise_Question', 'survey_question', 'Link_Category',
1531
        ];
1532
1533
        $need = [];
1534
        $addNeed = function (string $type, $id) use (&$need): void {
1535
            $t = (string) $type;
1536
            $i = is_numeric($id) ? (int) $id : (string) $id;
1537
            if ('' === $i || 0 === $i) {
1538
                return;
1539
            }
1540
            $need[$t] ??= [];
1541
            $need[$t][$i] = true;
1542
        };
1543
1544
        foreach ($course->resources['learnpath'] as $lpId => $lpWrap) {
1545
            $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap;
1546
1547
            if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) {
1548
                foreach ($lpWrap->linked_resources as $t => $ids) {
1549
                    if (!\is_array($ids)) {
1550
                        continue;
1551
                    }
1552
                    foreach ($ids as $rid) {
1553
                        $addNeed($t, $rid);
1554
                    }
1555
                }
1556
            }
1557
1558
            $items = [];
1559
            if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) {
1560
                $items = $lp->items;
1561
            } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) {
1562
                $items = $lpWrap->items;
1563
            }
1564
1565
            foreach ($items as $it) {
1566
                $ito = \is_object($it) ? $it : (object) $it;
1567
1568
                if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) {
1569
                    foreach ($ito->linked_resources as $t => $ids) {
1570
                        if (!\is_array($ids)) {
1571
                            continue;
1572
                        }
1573
                        foreach ($ids as $rid) {
1574
                            $addNeed($t, $rid);
1575
                        }
1576
                    }
1577
                }
1578
1579
                foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) {
1580
                    if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) {
1581
                        $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field};
1582
                        $t = $typeGuess ?: (string) ($ito->type ?? '');
1583
                        if ('' !== $t) {
1584
                            $addNeed($t, $rid);
1585
                        }
1586
                    }
1587
                }
1588
1589
                if (!empty($ito->type) && isset($ito->ref)) {
1590
                    $addNeed((string) $ito->type, $ito->ref);
1591
                }
1592
            }
1593
        }
1594
1595
        if (empty($need)) {
1596
            $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'];
1597
            foreach ($core as $k) {
1598
                if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) {
1599
                    $course->resources[$k] ??= [];
1600
                    if (0 === \count($course->resources[$k])) {
1601
                        $course->resources[$k] = $snapshot[$k];
1602
                    }
1603
                }
1604
            }
1605
            $this->logDebug('[LP-deps] fallback filled from snapshot', [
1606
                '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)),
1607
            ]);
1608
1609
            return;
1610
        }
1611
1612
        foreach ($need as $type => $idMap) {
1613
            if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) {
1614
                continue;
1615
            }
1616
1617
            $course->resources[$type] ??= [];
1618
1619
            foreach (array_keys($idMap) as $rid) {
1620
                $src = $snapshot[$type][$rid]
1621
                    ?? $snapshot[$type][(string) $rid]
1622
                    ?? null;
1623
1624
                if (!$src) {
1625
                    continue;
1626
                }
1627
1628
                if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) {
1629
                    $course->resources[$type][$rid] = $src;
1630
                }
1631
            }
1632
        }
1633
1634
        $this->logDebug('[LP-deps] hydrated', [
1635
            'types' => array_keys($need),
1636
            'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)),
1637
        ]);
1638
    }
1639
1640
    /**
1641
     * Build a Vue-friendly tree from legacy Course.
1642
     */
1643
    private function buildResourceTreeForVue(object $course): array
1644
    {
1645
        if ($this->debug) {
1646
            $this->logDebug('[buildResourceTreeForVue] start');
1647
        }
1648
1649
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1650
            ? $course->resources
1651
            : [];
1652
1653
        $legacyTitles = [];
1654
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1655
            /** @var array<string,string> $legacyTitles */
1656
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1657
        }
1658
        $fallbackTitles = $this->getDefaultTypeTitles();
1659
        $skipTypes = $this->getSkipTypeKeys();
1660
1661
        $tree = [];
1662
1663
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1664
            $docs = $resources['document'];
1665
1666
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1667
                $p = trim($rawPath, '/');
1668
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1669
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1670
1671
                // host
1672
                if (!empty($parts) && ($parts[0] === 'localhost' || str_contains($parts[0], '.'))) {
1673
                    array_shift($parts);
1674
                }
1675
                // course-code
1676
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1677
                    array_shift($parts);
1678
                }
1679
1680
                $clean = implode('/', $parts);
1681
                if ($clean === '' && $filetype !== 'folder') {
1682
                    $clean = $title;
1683
                }
1684
                if ($filetype === 'folder') {
1685
                    $clean = rtrim($clean, '/').'/';
1686
                }
1687
                return $clean;
1688
            };
1689
1690
            $folderIdByPath = [];
1691
            foreach ($docs as $obj) {
1692
                if (!\is_object($obj)) { continue; }
1693
                $ft = (string)($obj->filetype ?? $obj->file_type ?? '');
1694
                if ($ft !== 'folder') { continue; }
1695
                $rel = $normalize((string)$obj->path, (string)$obj->title, $ft);
1696
                $key = rtrim($rel, '/');
1697
                if ($key !== '') {
1698
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1699
                }
1700
            }
1701
1702
            $docRoot = [];
1703
            $findChild = static function (array &$children, string $label): ?int {
1704
                foreach ($children as $i => $n) {
1705
                    if ((string)($n['label'] ?? '') === $label) { return $i; }
1706
                }
1707
                return null;
1708
            };
1709
1710
            foreach ($docs as $obj) {
1711
                if (!\is_object($obj)) { continue; }
1712
1713
                $title    = (string) $obj->title;
1714
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1715
                $rel      = $normalize((string) $obj->path, $title, $filetype);
1716
                $parts    = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1717
1718
                $cursor =& $docRoot;
1719
                $soFar = '';
1720
                $total = \count($parts);
1721
1722
                for ($i = 0; $i < $total; $i++) {
1723
                    $seg      = $parts[$i];
1724
                    $isLast   = ($i === $total - 1);
1725
                    $isFolder = (!$isLast) || ($filetype === 'folder');
1726
1727
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1728
                    $label = $seg . ($isFolder ? '/' : '');
1729
1730
                    $idx = $findChild($cursor, $label);
1731
                    if ($idx === null) {
1732
                        if ($isFolder) {
1733
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1734
                            $node = [
1735
                                'id'         => $folderId ?? ('dir:'.$soFar),
1736
                                'label'      => $label,
1737
                                'selectable' => true,
1738
                                'children'   => [],
1739
                            ];
1740
                        } else {
1741
                            $node = [
1742
                                'id'         => (int) $obj->source_id,
1743
                                'label'      => $label,
1744
                                'selectable' => true,
1745
                            ];
1746
                        }
1747
                        $cursor[] = $node;
1748
                        $idx = \count($cursor) - 1;
1749
                    }
1750
1751
                    if ($isFolder) {
1752
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1753
                            $cursor[$idx]['children'] = [];
1754
                        }
1755
                        $cursor =& $cursor[$idx]['children'];
1756
                    }
1757
                }
1758
            }
1759
1760
            $sortTree = null;
1761
            $sortTree = function (array &$nodes) use (&$sortTree) {
1762
                usort($nodes, static fn($a, $b) => strcasecmp((string)$a['label'], (string)$b['label']));
1763
                foreach ($nodes as &$n) {
1764
                    if (isset($n['children']) && \is_array($n['children'])) {
1765
                        $sortTree($n['children']);
1766
                    }
1767
                }
1768
            };
1769
            $sortTree($docRoot);
1770
1771
            $tree[] = [
1772
                'type'     => 'document',
1773
                'title'    => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1774
                'children' => $docRoot,
1775
            ];
1776
1777
            $skipTypes['document'] = true;
1778
        }
1779
1780
        // Forums block
1781
        $hasForumData =
1782
            (!empty($resources['forum']) || !empty($resources['Forum']))
1783
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1784
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1785
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1786
1787
        if ($hasForumData) {
1788
            $tree[] = $this->buildForumTreeForVue(
1789
                $course,
1790
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1791
            );
1792
            $skipTypes['forum'] = true;
1793
            $skipTypes['forum_category'] = true;
1794
            $skipTypes['forum_topic'] = true;
1795
            $skipTypes['forum_post'] = true;
1796
            $skipTypes['thread'] = true;
1797
            $skipTypes['post'] = true;
1798
        }
1799
1800
        // Links block (Category → Link)
1801
        $hasLinkData =
1802
            (!empty($resources['link']) || !empty($resources['Link']))
1803
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1804
1805
        if ($hasLinkData) {
1806
            $tree[] = $this->buildLinkTreeForVue(
1807
                $course,
1808
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1809
            );
1810
            $skipTypes['link'] = true;
1811
            $skipTypes['link_category'] = true;
1812
        }
1813
1814
        foreach ($resources as $rawType => $items) {
1815
            if (!\is_array($items) || empty($items)) {
1816
                continue;
1817
            }
1818
            $typeKey = $this->normalizeTypeKey($rawType);
1819
            if (isset($skipTypes[$typeKey])) {
1820
                continue;
1821
            }
1822
1823
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1824
            $group = [
1825
                'type' => $typeKey,
1826
                'title' => (string) $groupTitle,
1827
                'items' => [],
1828
            ];
1829
1830
            if ('gradebook' === $typeKey) {
1831
                $group['items'][] = [
1832
                    'id' => 'all',
1833
                    'label' => 'Gradebook (all)',
1834
                    'extra' => new stdClass(),
1835
                    'selectable' => true,
1836
                ];
1837
                $tree[] = $group;
1838
                continue;
1839
            }
1840
1841
            foreach ($items as $id => $obj) {
1842
                if (!\is_object($obj)) {
1843
                    continue;
1844
                }
1845
1846
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1847
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1848
                    continue;
1849
                }
1850
1851
                if (!$this->isSelectableItem($typeKey, $obj)) {
1852
                    continue;
1853
                }
1854
1855
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1856
1857
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1858
                    $label = $idKey;
1859
                }
1860
1861
                $extra = $this->buildExtra($typeKey, $obj);
1862
1863
                $group['items'][] = [
1864
                    'id' => $idKey,
1865
                    'label' => $label,
1866
                    'extra' => $extra ?: new stdClass(),
1867
                    'selectable' => true,
1868
                ];
1869
            }
1870
1871
            if (!empty($group['items'])) {
1872
                usort(
1873
                    $group['items'],
1874
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1875
                );
1876
                $tree[] = $group;
1877
            }
1878
        }
1879
1880
        // Preferred order
1881
        $preferredOrder = [
1882
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1883
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook',
1884
        ];
1885
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1886
            $ia = array_search($a['type'], $preferredOrder, true);
1887
            $ib = array_search($b['type'], $preferredOrder, true);
1888
            if (false !== $ia && false !== $ib) {
1889
                return $ia <=> $ib;
1890
            }
1891
            if (false !== $ia) {
1892
                return -1;
1893
            }
1894
            if (false !== $ib) {
1895
                return 1;
1896
            }
1897
1898
            return strcasecmp($a['title'], $b['title']);
1899
        });
1900
1901
        if ($this->debug) {
1902
            $this->logDebug(
1903
                '[buildResourceTreeForVue] end groups',
1904
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
1905
            );
1906
        }
1907
1908
        return $tree;
1909
    }
1910
1911
1912
    /**
1913
     * Build forum tree (Category → Forum → Topic) for the UI.
1914
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
1915
     */
1916
    private function buildForumTreeForVue(object $course, string $groupTitle): array
1917
    {
1918
        $this->logDebug('[buildForumTreeForVue] start');
1919
1920
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
1921
1922
        // Buckets (defensive: accept legacy casings / aliases)
1923
        $catRaw   = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
1924
        $forumRaw = $res['forum']          ?? $res['Forum']          ?? [];
1925
        $topicRaw = $res['forum_topic']    ?? $res['ForumTopic']     ?? ($res['thread'] ?? []);
1926
        $postRaw  = $res['forum_post']     ?? $res['Forum_Post']     ?? ($res['post'] ?? []);
1927
1928
        $this->logDebug('[buildForumTreeForVue] raw counts', [
1929
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
1930
            'forums'     => \is_array($forumRaw) ? \count($forumRaw) : 0,
1931
            'topics'     => \is_array($topicRaw) ? \count($topicRaw) : 0,
1932
            'posts'      => \is_array($postRaw) ? \count($postRaw) : 0,
1933
        ]);
1934
1935
        // Quick classifiers (defensive)
1936
        $isForum = function (object $o): bool {
1937
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1938
            if (isset($e->forum_title) && \is_string($e->forum_title)) { return true; }
1939
            if (isset($e->default_view) || isset($e->allow_anonymous)) { return true; }
1940
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { return true; }
1941
            return false;
1942
        };
1943
        $isTopic = function (object $o): bool {
1944
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
1945
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { return true; }
1946
            if (isset($e->forum_id) && !isset($e->forum_title)) { return true; }
1947
            return false;
1948
        };
1949
        $getForumCategoryId = function (object $forum): int {
1950
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
1951
            $cid = (int) ($e->forum_category ?? 0);
1952
            if ($cid <= 0) { $cid = (int) ($e->forum_category_id ?? 0); }
1953
            if ($cid <= 0) { $cid = (int) ($e->category_id ?? 0); }
1954
            return $cid;
1955
        };
1956
1957
        // Build categories
1958
        $cats = [];
1959
        foreach ($catRaw as $id => $obj) {
1960
            $id = (int) $id;
1961
            if ($id <= 0 || !\is_object($obj)) { continue; }
1962
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
1963
            $cats[$id] = [
1964
                'id'         => $id,
1965
                'type'       => 'forum_category',
1966
                'label'      => ($label !== '' ? $label : 'Category #'.$id).'/',
1967
                'selectable' => true,
1968
                'items'      => [],
1969
                'has_children' => false,
1970
                'item_count'   => 0,
1971
                'extra'      => ['filetype' => 'folder'],
1972
            ];
1973
        }
1974
        // Virtual "Uncategorized"
1975
        $uncatKey = -9999;
1976
        if (!isset($cats[$uncatKey])) {
1977
            $cats[$uncatKey] = [
1978
                'id'           => $uncatKey,
1979
                'type'         => 'forum_category',
1980
                'label'        => 'Uncategorized/',
1981
                'selectable'   => true,
1982
                'items'        => [],
1983
                '_virtual'     => true,
1984
                'has_children' => false,
1985
                'item_count'   => 0,
1986
                'extra'        => ['filetype' => 'folder'],
1987
            ];
1988
        }
1989
1990
        // Forums
1991
        $forums = [];
1992
        foreach ($forumRaw as $id => $obj) {
1993
            $id = (int) $id;
1994
            if ($id <= 0 || !\is_object($obj)) { continue; }
1995
            if (!$isForum($obj)) {
1996
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
1997
                continue;
1998
            }
1999
            $forums[$id] = $this->objectEntity($obj);
2000
        }
2001
2002
        // Topics (+ post counts)
2003
        $topics = [];
2004
        $postCountByTopic = [];
2005
        foreach ($topicRaw as $id => $obj) {
2006
            $id = (int) $id;
2007
            if ($id <= 0 || !\is_object($obj)) { continue; }
2008
            if ($isForum($obj) && !$isTopic($obj)) {
2009
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2010
                continue;
2011
            }
2012
            if (!$isTopic($obj)) { continue; }
2013
            $topics[$id] = $this->objectEntity($obj);
2014
        }
2015
        foreach ($postRaw as $id => $obj) {
2016
            $id = (int) $id;
2017
            if ($id <= 0 || !\is_object($obj)) { continue; }
2018
            $e = $this->objectEntity($obj);
2019
            $tid = (int) ($e->thread_id ?? 0);
2020
            if ($tid > 0) { $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; }
2021
        }
2022
2023
        // Attach topics to forums and forums to categories
2024
        foreach ($forums as $fid => $f) {
2025
            $catId = $getForumCategoryId($f);
2026
            if (!isset($cats[$catId])) { $catId = $uncatKey; }
2027
2028
            $forumNode = [
2029
                'id'         => $fid,
2030
                'type'       => 'forum',
2031
                'label'      => $this->resolveItemLabel('forum', $f, $fid),
2032
                'extra'      => $this->buildExtra('forum', $f) ?: new \stdClass(),
2033
                'selectable' => true,
2034
                'items'      => [],
2035
                // UI hints
2036
                'has_children' => false,
2037
                'item_count'   => 0,
2038
                'ui_depth'     => 2,
2039
            ];
2040
2041
            foreach ($topics as $tid => $t) {
2042
                if ((int) ($t->forum_id ?? 0) !== $fid) { continue; }
2043
2044
                $author  = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2045
                $date    = (string) ($t->thread_date ?? '');
2046
                $nPosts  = (int) ($postCountByTopic[$tid] ?? 0);
2047
2048
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2049
                $meta = [];
2050
                if ($author !== '') { $meta[] = $author; }
2051
                if ($date   !== '') { $meta[] = $date; }
2052
                if ($meta) { $topicLabel .= ' ('.implode(', ', $meta).')'; }
2053
                if ($nPosts > 0) { $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); }
2054
2055
                $forumNode['items'][] = [
2056
                    'id'         => $tid,
2057
                    'type'       => 'forum_topic',
2058
                    'label'      => $topicLabel,
2059
                    'extra'      => new \stdClass(),
2060
                    'selectable' => true,
2061
                    'ui_depth'   => 3,
2062
                    'item_count' => 0,
2063
                ];
2064
            }
2065
2066
            if (!empty($forumNode['items'])) {
2067
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2068
                $forumNode['has_children'] = true;
2069
                $forumNode['item_count']   = \count($forumNode['items']);
2070
            }
2071
2072
            $cats[$catId]['items'][] = $forumNode;
2073
        }
2074
2075
        // Remove empty virtual category; sort forums inside each category
2076
        $catNodes = array_values(array_filter($cats, static function ($c) {
2077
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
2078
            return true;
2079
        }));
2080
2081
        // Flatten stray forums (defensive) and finalize UI hints
2082
        foreach ($catNodes as &$cat) {
2083
            if (!empty($cat['items'])) {
2084
                $lift = [];
2085
                foreach ($cat['items'] as &$forumNode) {
2086
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) { continue; }
2087
                    $keep = [];
2088
                    foreach ($forumNode['items'] as $child) {
2089
                        if (($child['type'] ?? '') === 'forum') {
2090
                            $lift[] = $child;
2091
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2092
                                'parent_forum_id' => $forumNode['id'] ?? null,
2093
                                'lifted_forum_id' => $child['id'] ?? null,
2094
                                'cat_id'          => $cat['id'] ?? null,
2095
                            ]);
2096
                        } else {
2097
                            $keep[] = $child;
2098
                        }
2099
                    }
2100
                    $forumNode['items']        = $keep;
2101
                    $forumNode['has_children'] = !empty($keep);
2102
                    $forumNode['item_count']   = \count($keep);
2103
                }
2104
                unset($forumNode);
2105
2106
                foreach ($lift as $n) { $cat['items'][] = $n; }
2107
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2108
            }
2109
2110
            // UI hints for category
2111
            $cat['has_children'] = !empty($cat['items']);
2112
            $cat['item_count']   = \count($cat['items'] ?? []);
2113
        }
2114
        unset($cat);
2115
2116
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2117
2118
        return [
2119
            'type'  => 'forum',
2120
            'title' => $groupTitle,
2121
            'items' => $catNodes,
2122
        ];
2123
    }
2124
2125
    /**
2126
     * Normalize a raw type to a lowercase key.
2127
     */
2128
    private function normalizeTypeKey(int|string $raw): string
2129
    {
2130
        if (\is_int($raw)) {
2131
            return (string) $raw;
2132
        }
2133
2134
        $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw));
2135
2136
        $map = [
2137
            'forum_category' => 'forum_category',
2138
            'forumtopic' => 'forum_topic',
2139
            'forum_topic' => 'forum_topic',
2140
            'forum_post' => 'forum_post',
2141
            'thread' => 'forum_topic',
2142
            'post' => 'forum_post',
2143
            'exercise_question' => 'exercise_question',
2144
            'surveyquestion' => 'survey_question',
2145
            'surveyinvitation' => 'survey_invitation',
2146
            'survey' => 'survey',
2147
            'link_category' => 'link_category',
2148
            'coursecopylearnpath' => 'learnpath',
2149
            'coursecopytestcategory' => 'test_category',
2150
            'coursedescription' => 'course_description',
2151
            'session_course' => 'session_course',
2152
            'gradebookbackup' => 'gradebook',
2153
            'scormdocument' => 'scorm',
2154
            'tool/introduction' => 'tool_intro',
2155
            'tool_introduction' => 'tool_intro',
2156
        ];
2157
2158
        return $map[$s] ?? $s;
2159
    }
2160
2161
    /**
2162
     * Keys to skip as top-level groups in UI.
2163
     *
2164
     * @return array<string,bool>
2165
     */
2166
    private function getSkipTypeKeys(): array
2167
    {
2168
        return [
2169
            'forum_category' => true,
2170
            'forum_topic' => true,
2171
            'forum_post' => true,
2172
            'thread' => true,
2173
            'post' => true,
2174
            'exercise_question' => true,
2175
            'survey_question' => true,
2176
            'survey_invitation' => true,
2177
            'session_course' => true,
2178
            'scorm' => true,
2179
            'asset' => true,
2180
            'link_category' => true,
2181
        ];
2182
    }
2183
2184
    /**
2185
     * Default labels for groups.
2186
     *
2187
     * @return array<string,string>
2188
     */
2189
    private function getDefaultTypeTitles(): array
2190
    {
2191
        return [
2192
            'announcement' => 'Announcements',
2193
            'document' => 'Documents',
2194
            'glossary' => 'Glossaries',
2195
            'calendar_event' => 'Calendar events',
2196
            'event' => 'Calendar events',
2197
            'link' => 'Links',
2198
            'course_description' => 'Course descriptions',
2199
            'learnpath' => 'Parcours',
2200
            'learnpath_category' => 'Learning path categories',
2201
            'forum' => 'Forums',
2202
            'forum_category' => 'Forum categories',
2203
            'quiz' => 'Exercices',
2204
            'test_category' => 'Test categories',
2205
            'wiki' => 'Wikis',
2206
            'thematic' => 'Thematics',
2207
            'attendance' => 'Attendances',
2208
            'work' => 'Works',
2209
            'session_course' => 'Session courses',
2210
            'gradebook' => 'Gradebook',
2211
            'scorm' => 'SCORM packages',
2212
            'survey' => 'Surveys',
2213
            'survey_question' => 'Survey questions',
2214
            'survey_invitation' => 'Survey invitations',
2215
            'asset' => 'Assets',
2216
            'tool_intro' => 'Tool introductions',
2217
        ];
2218
    }
2219
2220
    /**
2221
     * Decide if an item is selectable (UI).
2222
     */
2223
    private function isSelectableItem(string $type, object $obj): bool
2224
    {
2225
        if ('document' === $type) {
2226
            return true;
2227
        }
2228
2229
        return true;
2230
    }
2231
2232
    /**
2233
     * Resolve label for an item with fallbacks.
2234
     */
2235
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2236
    {
2237
        $entity = $this->objectEntity($obj);
2238
2239
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2240
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2241
                return trim((string) $entity->{$k});
2242
            }
2243
        }
2244
2245
        if (isset($obj->params) && \is_array($obj->params)) {
2246
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2247
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2248
                    return (string) $obj->params[$k];
2249
                }
2250
            }
2251
        }
2252
2253
        switch ($type) {
2254
            case 'document':
2255
                // 1) ruta cruda tal como viene del backup/DB
2256
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2257
                if ('' !== $raw) {
2258
                    // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup
2259
                    $rel = ltrim($raw, '/');
2260
                    $rel = preg_replace('~^document/?~', '', $rel);
2261
2262
                    // 3) carpeta ⇒ que termine con "/"
2263
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2264
                    if ('folder' === $fileType) {
2265
                        $rel = rtrim($rel, '/').'/';
2266
                    }
2267
2268
                    // 4) si la ruta quedó vacía, usa basename como último recurso
2269
                    return '' !== $rel ? $rel : basename($raw);
2270
                }
2271
2272
                // fallback: título o nombre de archivo
2273
                if (!empty($obj->title)) {
2274
                    return (string) $obj->title;
2275
                }
2276
2277
                break;
2278
2279
            case 'course_description':
2280
                if (!empty($obj->title)) {
2281
                    return (string) $obj->title;
2282
                }
2283
                $t = (int) ($obj->description_type ?? 0);
2284
                $names = [
2285
                    1 => 'Description',
2286
                    2 => 'Objectives',
2287
                    3 => 'Topics',
2288
                    4 => 'Methodology',
2289
                    5 => 'Course material',
2290
                    6 => 'Resources',
2291
                    7 => 'Assessment',
2292
                    8 => 'Custom',
2293
                ];
2294
2295
                return $names[$t] ?? ('#'.$fallbackId);
2296
2297
            case 'announcement':
2298
                if (!empty($obj->title)) {
2299
                    return (string) $obj->title;
2300
                }
2301
2302
                break;
2303
2304
            case 'forum':
2305
                if (!empty($entity->forum_title)) {
2306
                    return (string) $entity->forum_title;
2307
                }
2308
2309
                break;
2310
2311
            case 'forum_category':
2312
                if (!empty($entity->cat_title)) {
2313
                    return (string) $entity->cat_title;
2314
                }
2315
2316
                break;
2317
2318
            case 'link':
2319
                if (!empty($obj->title)) {
2320
                    return (string) $obj->title;
2321
                }
2322
                if (!empty($obj->url)) {
2323
                    return (string) $obj->url;
2324
                }
2325
2326
                break;
2327
2328
            case 'survey':
2329
                if (!empty($obj->title)) {
2330
                    return trim((string) $obj->title);
2331
                }
2332
2333
                break;
2334
2335
            case 'learnpath':
2336
                if (!empty($obj->name)) {
2337
                    return (string) $obj->name;
2338
                }
2339
2340
                break;
2341
2342
            case 'thematic':
2343
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2344
                    return (string) $obj->params['title'];
2345
                }
2346
2347
                break;
2348
2349
            case 'quiz':
2350
                if (!empty($entity->title)) {
2351
                    return (string) $entity->title;
2352
                }
2353
2354
                break;
2355
2356
            case 'forum_topic':
2357
                if (!empty($entity->thread_title)) {
2358
                    return (string) $entity->thread_title;
2359
                }
2360
2361
                break;
2362
        }
2363
2364
        return '#'.$fallbackId;
2365
    }
2366
2367
    /**
2368
     * Extract wrapped entity (->obj) or the object itself.
2369
     */
2370
    private function objectEntity(object $resource): object
2371
    {
2372
        if (isset($resource->obj) && \is_object($resource->obj)) {
2373
            return $resource->obj;
2374
        }
2375
2376
        return $resource;
2377
    }
2378
2379
    /**
2380
     * Extra payload per item for UI (optional).
2381
     */
2382
    private function buildExtra(string $type, object $obj): array
2383
    {
2384
        $extra = [];
2385
2386
        $get = static function (object $o, string $k, $default = null) {
2387
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2388
        };
2389
2390
        switch ($type) {
2391
            case 'document':
2392
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2393
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2394
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2395
2396
                break;
2397
2398
            case 'link':
2399
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2400
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2401
2402
                break;
2403
2404
            case 'forum':
2405
                $entity = $this->objectEntity($obj);
2406
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2407
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2408
2409
                break;
2410
2411
            case 'learnpath':
2412
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2413
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2414
                    return [
2415
                        'id' => (int) ($i['id'] ?? 0),
2416
                        'title' => (string) ($i['title'] ?? ''),
2417
                        'type' => (string) ($i['item_type'] ?? ''),
2418
                        'path' => (string) ($i['path'] ?? ''),
2419
                    ];
2420
                }, $obj->items) : [];
2421
2422
                break;
2423
2424
            case 'thematic':
2425
                if (isset($obj->params) && \is_array($obj->params)) {
2426
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2427
                }
2428
2429
                break;
2430
2431
            case 'quiz':
2432
                $entity = $this->objectEntity($obj);
2433
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2434
                    ? array_map('intval', $entity->question_ids)
2435
                    : [];
2436
2437
                break;
2438
2439
            case 'survey':
2440
                $entity = $this->objectEntity($obj);
2441
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2442
                    ? array_map('intval', $entity->question_ids)
2443
                    : [];
2444
2445
                break;
2446
        }
2447
2448
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2449
    }
2450
2451
    // --------------------------------------------------------------------------------
2452
    // Selection filtering (used by partial restore)
2453
    // --------------------------------------------------------------------------------
2454
2455
    /**
2456
     * Get first existing key from candidates.
2457
     */
2458
    private function firstExistingKey(array $orig, array $candidates): ?string
2459
    {
2460
        foreach ($candidates as $k) {
2461
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2462
                return $k;
2463
            }
2464
        }
2465
2466
        return null;
2467
    }
2468
2469
    /**
2470
     * Filter legacy Course by UI selections (and pull dependencies).
2471
     *
2472
     * @param array $selected [type => [id => true]]
2473
     */
2474
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2475
    {
2476
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2477
        $selected = array_filter($selected, 'is_array');
2478
        unset($selected['undefined']);
2479
2480
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2481
2482
        if (empty($course->resources) || !\is_array($course->resources)) {
2483
            $this->logDebug('[filterSelection] course has no resources');
2484
2485
            return $course;
2486
        }
2487
2488
        /** @var array<string,mixed> $orig */
2489
        $orig = $course->resources;
2490
2491
        // Preserve meta buckets (keys that start with "__")
2492
        $__metaBuckets = [];
2493
        foreach ($orig as $k => $v) {
2494
            if (\is_string($k) && str_starts_with($k, '__')) {
2495
                $__metaBuckets[$k] = $v;
2496
            }
2497
        }
2498
2499
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2500
2501
        // ---------- Forums flow ----------
2502
        if (!empty($selected['forum'])) {
2503
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2504
            if (!empty($selForums)) {
2505
                // tolerant lookups
2506
                $forums  = $this->findBucket($orig, 'forum');
2507
                $threads = $this->findBucket($orig, 'forum_topic');
2508
                $posts   = $this->findBucket($orig, 'forum_post');
2509
2510
                $catsToKeep = [];
2511
2512
                foreach ($forums as $fid => $f) {
2513
                    if (!isset($selForums[(string) $fid])) {
2514
                        continue;
2515
                    }
2516
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2517
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2518
                    if ($cid > 0) {
2519
                        $catsToKeep[$cid] = true;
2520
                    }
2521
                }
2522
2523
                $threadToKeep = [];
2524
                foreach ($threads as $tid => $t) {
2525
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2526
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2527
                        $threadToKeep[(int) $tid] = true;
2528
                    }
2529
                }
2530
2531
                $postToKeep = [];
2532
                foreach ($posts as $pid => $p) {
2533
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2534
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2535
                        $postToKeep[(int) $pid] = true;
2536
                    }
2537
                }
2538
2539
                $out = [];
2540
                foreach ($selected as $type => $ids) {
2541
                    if (!\is_array($ids) || empty($ids)) {
2542
                        continue;
2543
                    }
2544
                    $bucket = $this->findBucket($orig, (string) $type);
2545
                    $key    = $this->findBucketKey($orig, (string) $type);
2546
                    if ($key !== null && !empty($bucket)) {
2547
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2548
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2549
                    }
2550
                }
2551
2552
                $forumCat     = $this->findBucket($orig, 'forum_category');
2553
                $forumBucket  = $this->findBucket($orig, 'forum');
2554
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2555
                $postBucket   = $this->findBucket($orig, 'forum_post');
2556
2557
                if (!empty($forumCat) && !empty($catsToKeep)) {
2558
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2559
                        array_intersect_key(
2560
                            $forumCat,
2561
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2562
                        );
2563
                }
2564
2565
                if (!empty($forumBucket)) {
2566
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2567
                        array_intersect_key($forumBucket, $selForums);
2568
                }
2569
                if (!empty($threadBucket)) {
2570
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2571
                        array_intersect_key(
2572
                            $threadBucket,
2573
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2574
                        );
2575
                }
2576
                if (!empty($postBucket)) {
2577
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2578
                        array_intersect_key(
2579
                            $postBucket,
2580
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2581
                        );
2582
                }
2583
2584
                // If we have forums but no Forum_Category (edge), keep original categories
2585
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2586
                    $out['Forum_Category'] = $forumCat;
2587
                }
2588
2589
                $out = array_filter($out);
2590
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2591
2592
                $this->logDebug('[filterSelection] end (forums)', [
2593
                    'kept_types' => array_keys($course->resources),
2594
                    'forum_counts' => [
2595
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2596
                        'forum'          => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2597
                        'thread'         => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2598
                        'post'           => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2599
                    ],
2600
                ]);
2601
2602
                return $course;
2603
            }
2604
        }
2605
2606
        // ---------- Generic + quiz/survey/gradebook ----------
2607
        $keep = [];
2608
        foreach ($selected as $type => $ids) {
2609
            if (!\is_array($ids) || empty($ids)) {
2610
                continue;
2611
            }
2612
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2613
            if ($legacyKey === null) {
2614
                continue;
2615
            }
2616
            $bucket = $orig[$legacyKey] ?? [];
2617
            if (!empty($bucket) && \is_array($bucket)) {
2618
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2619
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2620
            }
2621
        }
2622
2623
        // Gradebook
2624
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2625
        if ($gbKey && !empty($selected['gradebook'])) {
2626
            $gbBucket = $getBucket($orig, $gbKey);
2627
            if (!empty($gbBucket)) {
2628
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2629
                $firstItem = reset($gbBucket);
2630
2631
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2632
                    $keep[$gbKey] = $gbBucket;
2633
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2634
                } else {
2635
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2636
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2637
                }
2638
            }
2639
        }
2640
2641
        // Quizzes -> questions (+ images)
2642
        $quizKey = $this->firstExistingKey($orig, ['quiz','Quiz']);
2643
        if ($quizKey && !empty($keep[$quizKey])) {
2644
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question','exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2645
            if ($questionKey) {
2646
                $qids = [];
2647
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2648
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2649
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2650
                        foreach ($q->question_ids as $sid) {
2651
                            $qids[(string) $sid] = true;
2652
                        }
2653
                    }
2654
                }
2655
                if (!empty($qids)) {
2656
                    $questionBucket = $getBucket($orig, $questionKey);
2657
                    $selQ = array_intersect_key($questionBucket, $qids);
2658
                    if (!empty($selQ)) {
2659
                        $keep[$questionKey] = $selQ;
2660
2661
                        $docKey = $this->firstExistingKey($orig, ['document','Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2662
                        if ($docKey) {
2663
                            $docBucket = $getBucket($orig, $docKey);
2664
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2665
                            if (!empty($imageQuizBucket)) {
2666
                                $needed = [];
2667
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2668
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2669
                                    $pic = (string) ($q->picture ?? '');
2670
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2671
                                        $needed[$pic] = true;
2672
                                    }
2673
                                }
2674
                                if (!empty($needed)) {
2675
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2676
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2677
                                }
2678
                            }
2679
                        }
2680
                    }
2681
                }
2682
            } else {
2683
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2684
            }
2685
        }
2686
2687
        // Surveys -> questions (+ invitations)
2688
        $surveyKey = $this->firstExistingKey($orig, ['survey','Survey']);
2689
        if ($surveyKey && !empty($keep[$surveyKey])) {
2690
            $surveyQuestionKey   = $this->firstExistingKey($orig, ['Survey_Question','survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2691
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation','survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2692
2693
            if ($surveyQuestionKey) {
2694
                $neededQids   = [];
2695
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2696
2697
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2698
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2699
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2700
                        foreach ($s->question_ids as $qid) {
2701
                            $neededQids[(string) $qid] = true;
2702
                        }
2703
                    }
2704
                }
2705
                if (empty($neededQids)) {
2706
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2707
                    foreach ($surveyQBucket as $qid => $qWrap) {
2708
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2709
                        $qSurveyId = (string) ($q->survey_id ?? '');
2710
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2711
                            $neededQids[(string) $qid] = true;
2712
                        }
2713
                    }
2714
                }
2715
                if (!empty($neededQids)) {
2716
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2717
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2718
                }
2719
            } else {
2720
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2721
            }
2722
2723
            if ($surveyInvitationKey) {
2724
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2725
                if (!empty($invBucket)) {
2726
                    $neededInv = [];
2727
                    foreach ($invBucket as $iid => $invWrap) {
2728
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2729
                        $sid = (string) ($inv->survey_id ?? '');
2730
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2731
                            $neededInv[(string) $iid] = true;
2732
                        }
2733
                    }
2734
                    if (!empty($neededInv)) {
2735
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2736
                    }
2737
                }
2738
            }
2739
        }
2740
2741
        // Documents: add parent folders for selected files
2742
        $docKey = $this->firstExistingKey($orig, ['document','Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2743
        if ($docKey && !empty($keep[$docKey])) {
2744
            $docBucket = $getBucket($orig, $docKey);
2745
2746
            $foldersByRel = [];
2747
            foreach ($docBucket as $fid => $res) {
2748
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2749
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2750
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && substr((string) $e->path, -1) === '/');
2751
                if (!$isFolder) { continue; }
2752
2753
                $p = (string) ($e->path ?? '');
2754
                if ('' === $p) { continue; }
2755
2756
                $frel = '/'.ltrim(substr($p, 8), '/');
2757
                $frel = rtrim($frel, '/').'/';
2758
                if ('//' !== $frel) { $foldersByRel[$frel] = $fid; }
2759
            }
2760
2761
            $needFolderIds = [];
2762
            foreach ($keep[$docKey] as $id => $res) {
2763
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2764
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2765
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && substr((string) $e->path, -1) === '/');
2766
                if ($isFolder) { continue; }
2767
2768
                $p = (string) ($e->path ?? '');
2769
                if ('' === $p) { continue; }
2770
2771
                $rel = '/'.ltrim(substr($p, 8), '/');
2772
                $dir = rtrim(\dirname($rel), '/');
2773
                if ('' === $dir) { continue; }
2774
2775
                $acc = '';
2776
                foreach (array_filter(explode('/', $dir)) as $seg) {
2777
                    $acc .= '/'.$seg;
2778
                    $accKey = rtrim($acc, '/').'/';
2779
                    if (isset($foldersByRel[$accKey])) {
2780
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2781
                    }
2782
                }
2783
            }
2784
            if (!empty($needFolderIds)) {
2785
                $added = array_intersect_key($docBucket, $needFolderIds);
2786
                $keep[$docKey] += $added;
2787
            }
2788
        }
2789
2790
        // Links -> pull categories used by the selected links
2791
        $lnkKey = $this->firstExistingKey($orig, ['link','Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2792
        if ($lnkKey && !empty($keep[$lnkKey])) {
2793
            $catIdsUsed = [];
2794
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2795
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2796
                $cid = (int) ($L->category_id ?? 0);
2797
                if ($cid > 0) { $catIdsUsed[(string) $cid] = true; }
2798
            }
2799
2800
            $catKey = $this->firstExistingKey($orig, ['link_category','Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
2801
            if ($catKey && !empty($catIdsUsed)) {
2802
                $catBucket = $getBucket($orig, $catKey);
2803
                if (!empty($catBucket)) {
2804
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
2805
                    $keep[$catKey] = $subset;
2806
                    $keep['link_category'] = $subset; // mirror for convenience
2807
                }
2808
            }
2809
        }
2810
2811
        $keep = array_filter($keep);
2812
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
2813
2814
        $this->logDebug('[filterSelection] non-forum flow end', [
2815
            'selected_types' => array_keys($selected),
2816
            'orig_types'     => array_keys($orig),
2817
            'kept_types'     => array_keys($course->resources ?? []),
2818
        ]);
2819
2820
        return $course;
2821
    }
2822
2823
    /**
2824
     * Map UI options (1/2/3) to legacy file policy.
2825
     */
2826
    private function mapSameNameOption(int $opt): int
2827
    {
2828
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
2829
2830
        if (!\defined('FILE_SKIP')) {
2831
            \define('FILE_SKIP', 1);
2832
        }
2833
        if (!\defined('FILE_RENAME')) {
2834
            \define('FILE_RENAME', 2);
2835
        }
2836
        if (!\defined('FILE_OVERWRITE')) {
2837
            \define('FILE_OVERWRITE', 3);
2838
        }
2839
2840
        return match ($opt) {
2841
            1 => FILE_SKIP,
2842
            3 => FILE_OVERWRITE,
2843
            default => FILE_RENAME,
2844
        };
2845
    }
2846
2847
    /**
2848
     * Set debug mode from Request (query/header).
2849
     */
2850
    private function setDebugFromRequest(?Request $req): void
2851
    {
2852
        if (!$req) {
2853
            return;
2854
        }
2855
        // Query param wins
2856
        if ($req->query->has('debug')) {
2857
            $this->debug = $req->query->getBoolean('debug');
2858
2859
            return;
2860
        }
2861
        // Fallback to header
2862
        $hdr = $req->headers->get('X-Debug');
2863
        if (null !== $hdr) {
2864
            $val = trim((string) $hdr);
2865
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
2866
        }
2867
    }
2868
2869
    /**
2870
     * Debug logger with stage + compact JSON payload.
2871
     */
2872
    private function logDebug(string $stage, mixed $payload = null): void
2873
    {
2874
        if (!$this->debug) {
2875
            return;
2876
        }
2877
        $prefix = 'COURSE_DEBUG';
2878
        if (null === $payload) {
2879
            error_log("$prefix: $stage");
2880
2881
            return;
2882
        }
2883
        // Safe/short json
2884
        $json = null;
2885
2886
        try {
2887
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2888
            if (null !== $json && \strlen($json) > 8000) {
2889
                $json = substr($json, 0, 8000).'…(truncated)';
2890
            }
2891
        } catch (Throwable $e) {
2892
            $json = '[payload_json_error: '.$e->getMessage().']';
2893
        }
2894
        error_log("$prefix: $stage -> $json");
2895
    }
2896
2897
    /**
2898
     * Snapshot of resources bag for quick inspection.
2899
     */
2900
    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...
2901
    {
2902
        $out = [];
2903
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2904
        $i = 0;
2905
        foreach ($res as $type => $bag) {
2906
            if ($i++ >= $maxTypes) {
2907
                $out['__notice'] = 'types truncated';
2908
2909
                break;
2910
            }
2911
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
2912
            if (\is_array($bag)) {
2913
                $j = 0;
2914
                foreach ($bag as $id => $obj) {
2915
                    if ($j++ >= $maxItemsPerType) {
2916
                        $snap['sample'][] = ['__notice' => 'truncated'];
2917
2918
                        break;
2919
                    }
2920
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
2921
                    $snap['sample'][] = [
2922
                        'id' => (string) $id,
2923
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
2924
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
2925
                    ];
2926
                }
2927
            }
2928
            $out[(string) $type] = $snap;
2929
        }
2930
2931
        return $out;
2932
    }
2933
2934
    /**
2935
     * Snapshot of forum-family counters.
2936
     */
2937
    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...
2938
    {
2939
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
2940
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
2941
2942
        return [
2943
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
2944
            'forum' => $get('forum', 'Forum'),
2945
            'thread' => $get('thread', 'forum_topic'),
2946
            'post' => $get('post', 'forum_post'),
2947
        ];
2948
    }
2949
2950
    /**
2951
     * Builds the selection map [type => [id => true]] from high-level types.
2952
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
2953
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
2954
     *
2955
     * @param object   $course        Legacy Course with already hydrated resources
2956
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
2957
     *
2958
     * @return array<string, array<int|string, bool>>
2959
     */
2960
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
2961
    {
2962
        $selectedTypes = array_map(
2963
            fn ($t) => $this->normalizeTypeKey((string) $t),
2964
            $selectedTypes
2965
        );
2966
2967
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2968
2969
        $coreDeps = [
2970
            'document', 'link', 'quiz', 'work', 'survey',
2971
            'Forum_Category', 'forum', 'thread', 'post',
2972
            'exercise_question', 'survey_question', 'link_category',
2973
        ];
2974
2975
        $presentKeys = array_fill_keys(array_map(
2976
            fn ($k) => $this->normalizeTypeKey((string) $k),
2977
            array_keys($res)
2978
        ), true);
2979
2980
        $out = [];
2981
2982
        $addBucket = function (string $typeKey) use (&$out, $res): void {
2983
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
2984
                return;
2985
            }
2986
            $ids = [];
2987
            foreach ($res[$typeKey] as $id => $_) {
2988
                $ids[(string) $id] = true;
2989
            }
2990
            if ($ids) {
2991
                $out[$typeKey] = $ids;
2992
            }
2993
        };
2994
2995
        foreach ($selectedTypes as $t) {
2996
            $addBucket($t);
2997
2998
            if ('learnpath' === $t) {
2999
                foreach ($coreDeps as $depRaw) {
3000
                    $dep = $this->normalizeTypeKey($depRaw);
3001
                    if (isset($presentKeys[$dep])) {
3002
                        $addBucket($dep);
3003
                    }
3004
                }
3005
            }
3006
        }
3007
3008
        $this->logDebug('[buildSelectionFromTypes] built', [
3009
            'selectedTypes' => $selectedTypes,
3010
            'kept_types' => array_keys($out),
3011
        ]);
3012
3013
        return $out;
3014
    }
3015
3016
    /**
3017
     * Build link tree (Category → Link) for the UI.
3018
     * Categories are not selectable; links are leaves (item_count = 0).
3019
     */
3020
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3021
    {
3022
        $this->logDebug('[buildLinkTreeForVue] start');
3023
3024
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3025
        $catRaw  = $res['link_category'] ?? $res['Link_Category'] ?? [];
3026
        $linkRaw = $res['link']          ?? $res['Link']          ?? [];
3027
3028
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3029
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3030
            'links'      => \is_array($linkRaw) ? \count($linkRaw) : 0,
3031
        ]);
3032
3033
        $cats = [];
3034
        foreach ($catRaw as $id => $obj) {
3035
            $id = (int) $id;
3036
            if ($id <= 0 || !\is_object($obj)) { continue; }
3037
            $e = $this->objectEntity($obj);
3038
            $label = $this->resolveItemLabel('link_category', $e, $id);
3039
            $cats[$id] = [
3040
                'id'           => $id,
3041
                'type'         => 'link_category',
3042
                'label'        => (($label !== '' ? $label : ('Category #'.$id)).'/'),
3043
                'selectable'   => true,
3044
                'items'        => [],
3045
                'has_children' => false,
3046
                'item_count'   => 0,
3047
                'extra'        => ['filetype' => 'folder'],
3048
            ];
3049
        }
3050
3051
        // Virtual "Uncategorized"
3052
        $uncatKey = -9999;
3053
        if (!isset($cats[$uncatKey])) {
3054
            $cats[$uncatKey] = [
3055
                'id'           => $uncatKey,
3056
                'type'         => 'link_category',
3057
                'label'        => 'Uncategorized/',
3058
                'selectable'   => true,
3059
                'items'        => [],
3060
                '_virtual'     => true,
3061
                'has_children' => false,
3062
                'item_count'   => 0,
3063
                'extra'        => ['filetype' => 'folder'],
3064
            ];
3065
        }
3066
3067
        // Assign links to categories
3068
        foreach ($linkRaw as $id => $obj) {
3069
            $id = (int) $id;
3070
            if ($id <= 0 || !\is_object($obj)) { continue; }
3071
            $e = $this->objectEntity($obj);
3072
3073
            $cid = (int) ($e->category_id ?? 0);
3074
            if (!isset($cats[$cid])) { $cid = $uncatKey; }
3075
3076
            $cats[$cid]['items'][] = [
3077
                'id'         => $id,
3078
                'type'       => 'link',
3079
                'label'      => $this->resolveItemLabel('link', $e, $id),
3080
                'extra'      => $this->buildExtra('link', $e) ?: new \stdClass(),
3081
                'selectable' => true,
3082
                'item_count' => 0,
3083
            ];
3084
        }
3085
3086
        // Drop empty virtual category, sort, and finalize UI hints
3087
        $catNodes = array_values(array_filter($cats, static function ($c) {
3088
            if (!empty($c['_virtual']) && empty($c['items'])) { return false; }
3089
            return true;
3090
        }));
3091
3092
        foreach ($catNodes as &$c) {
3093
            if (!empty($c['items'])) {
3094
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3095
            }
3096
            $c['has_children'] = !empty($c['items']);
3097
            $c['item_count']   = \count($c['items'] ?? []);
3098
        }
3099
        unset($c);
3100
3101
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3102
3103
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3104
3105
        return [
3106
            'type'  => 'link',
3107
            'title' => $groupTitle,
3108
            'items' => $catNodes,
3109
        ];
3110
    }
3111
3112
    /**
3113
     * Leaves only the items selected by the UI in $course->resources.
3114
     * Expects $selected with the following form:
3115
     * [
3116
     * "documents" => ["123" => true, "124" => true],
3117
     * "links" => ["7" => true],
3118
     * "quiz" => ["45" => true],
3119
     * ...
3120
     * ].
3121
     */
3122
    private function filterCourseResources(object $course, array $selected): void
3123
    {
3124
        if (!isset($course->resources) || !\is_array($course->resources)) {
3125
            return;
3126
        }
3127
3128
        $typeMap = [
3129
            'documents' => RESOURCE_DOCUMENT,
3130
            'links' => RESOURCE_LINK,
3131
            'quizzes' => RESOURCE_QUIZ,
3132
            'quiz' => RESOURCE_QUIZ,
3133
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3134
            'surveys' => RESOURCE_SURVEY,
3135
            'survey' => RESOURCE_SURVEY,
3136
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3137
            'announcements' => RESOURCE_ANNOUNCEMENT,
3138
            'events' => RESOURCE_EVENT,
3139
            'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
3140
            'glossary' => RESOURCE_GLOSSARY,
3141
            'wiki' => RESOURCE_WIKI,
3142
            'thematic' => RESOURCE_THEMATIC,
3143
            'attendance' => RESOURCE_ATTENDANCE,
3144
            'works' => RESOURCE_WORK,
3145
            'gradebook' => RESOURCE_GRADEBOOK,
3146
            'learnpaths' => RESOURCE_LEARNPATH,
3147
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3148
            'tool_intro' => RESOURCE_TOOL_INTRO,
3149
            'forums' => RESOURCE_FORUM,
3150
            'forum' => RESOURCE_FORUM,
3151
            'forum_topic' => RESOURCE_FORUMTOPIC,
3152
            'forum_post' => RESOURCE_FORUMPOST,
3153
        ];
3154
3155
        $allowed = [];
3156
        foreach ($selected as $k => $idsMap) {
3157
            $key = $typeMap[$k] ?? $k;
3158
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3159
        }
3160
3161
        foreach ($course->resources as $rtype => $bucket) {
3162
            if (!isset($allowed[$rtype])) {
3163
                continue;
3164
            }
3165
            $keep = $allowed[$rtype];
3166
            $filtered = [];
3167
            foreach ((array) $bucket as $id => $obj) {
3168
                $iid = (int) ($obj->source_id ?? $id);
3169
                if (isset($keep[$iid])) {
3170
                    $filtered[$id] = $obj;
3171
                }
3172
            }
3173
            $course->resources[$rtype] = $filtered;
3174
        }
3175
    }
3176
3177
    /**
3178
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3179
     */
3180
    private function resolveBackupPath(string $backupId): string
3181
    {
3182
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3183
        $baseReal = realpath($base) ?: $base;
3184
3185
        $file = basename($backupId);
3186
        $path = $baseReal . DIRECTORY_SEPARATOR . $file;
3187
3188
        $real = realpath($path);
3189
3190
        if ($real !== false && strncmp($real, $baseReal, strlen($baseReal)) === 0) {
3191
            return $real;
3192
        }
3193
3194
        return $path;
3195
    }
3196
3197
    /**
3198
     * Load a legacy Course object from any backup:
3199
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3200
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3201
     *
3202
     * IMPORTANT:
3203
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3204
     * - Tries Moodle only when the package looks like Moodle.
3205
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3206
     */
3207
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3208
    {
3209
        $path = $this->resolveBackupPath($backupId);
3210
3211
        $force = strtolower($force);
3212
        if ('dat' === $force || 'chamilo' === $force) {
3213
            $looksMoodle = false;
3214
            $preferChamilo = true;
3215
        } elseif ('moodle' === $force) {
3216
            $looksMoodle = true;
3217
            $preferChamilo = false;
3218
        } else {
3219
            $looksMoodle   = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3220
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3221
        }
3222
3223
        if ($preferChamilo || !$looksMoodle) {
3224
            CourseArchiver::setDebug($this->debug);
3225
3226
            try {
3227
                $course = CourseArchiver::readCourse($backupId, false);
3228
                if (\is_object($course)) {
3229
                    // … (resto igual)
3230
                    if (!isset($course->resources) || !\is_array($course->resources)) { $course->resources = []; }
3231
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3232
                    $course->resources['__meta']['import_source'] = 'chamilo';
3233
                    return $course;
3234
                }
3235
            } catch (\Throwable $e) {
3236
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3237
            }
3238
3239
            $zipPath = $this->resolveBackupPath($backupId);
3240
            $ci = $this->readCourseInfoFromZip($zipPath);
3241
            if (empty($ci['ok'])) {
3242
                if ($looksMoodle) {
3243
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3244
                    return $this->loadMoodleCourseOrFail($path);
3245
                }
3246
                throw new \RuntimeException('course_info.dat not found in backup');
3247
            }
3248
3249
            $raw = (string) $ci['data'];
3250
            $payload = base64_decode($raw, true);
3251
            if ($payload === false) { $payload = $raw; }
3252
3253
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3254
            CourseArchiver::ensureLegacyAliases();
3255
3256
            set_error_handler(static function () {});
3257
            try {
3258
                if (class_exists(\UnserializeApi::class)) {
3259
                    $c = \UnserializeApi::unserialize('course', $payload);
3260
                } else {
3261
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3262
                }
3263
            } finally {
3264
                restore_error_handler();
3265
            }
3266
3267
            if (!\is_object($c ?? null)) {
3268
                if ($looksMoodle) {
3269
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3270
                    return $this->loadMoodleCourseOrFail($path);
3271
                }
3272
                throw new \RuntimeException('Could not unserialize course (fallback)');
3273
            }
3274
3275
            if (!isset($c->resources) || !\is_array($c->resources)) { $c->resources = []; }
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $c does not seem to be defined for all execution paths leading up to this point.
Loading history...
3276
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3277
            $c->resources['__meta']['import_source'] = 'chamilo';
3278
3279
            return $c;
3280
        }
3281
3282
        // Moodle path
3283
        if ($looksMoodle) {
3284
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3285
            return $this->loadMoodleCourseOrFail($path);
3286
        }
3287
3288
        throw new \RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3289
    }
3290
3291
    private function normalizeBucketsForRestorer(object $course): void
3292
    {
3293
        if (!isset($course->resources) || !\is_array($course->resources)) {
3294
            return;
3295
        }
3296
3297
        $map = [
3298
            'link' => RESOURCE_LINK,
3299
            'link_category' => RESOURCE_LINKCATEGORY,
3300
            'forum' => RESOURCE_FORUM,
3301
            'forum_category' => RESOURCE_FORUMCATEGORY,
3302
            'forum_topic' => RESOURCE_FORUMTOPIC,
3303
            'forum_post' => RESOURCE_FORUMPOST,
3304
            'thread' => RESOURCE_FORUMTOPIC,
3305
            'post' => RESOURCE_FORUMPOST,
3306
            'document' => RESOURCE_DOCUMENT,
3307
            'quiz' => RESOURCE_QUIZ,
3308
            'exercise_question' => RESOURCE_QUIZQUESTION,
3309
            'survey' => RESOURCE_SURVEY,
3310
            'survey_question' => RESOURCE_SURVEYQUESTION,
3311
            'tool_intro' => RESOURCE_TOOL_INTRO,
3312
        ];
3313
3314
        $res = $course->resources;
3315
        foreach ($map as $from => $to) {
3316
            if (isset($res[$from]) && \is_array($res[$from])) {
3317
                if (!isset($res[$to])) {
3318
                    $res[$to] = $res[$from];
3319
                }
3320
                unset($res[$from]);
3321
            }
3322
        }
3323
3324
        $course->resources = $res;
3325
    }
3326
3327
    /**
3328
     * Read import_source without depending on filtered resources.
3329
     * Falls back to $course->info['__import_source'] if needed.
3330
     */
3331
    private function getImportSource(object $course): string
3332
    {
3333
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3334
        if ('' !== $src) {
3335
            return $src;
3336
        }
3337
3338
        // Fallbacks (defensive)
3339
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3340
    }
3341
3342
    /**
3343
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3344
     * Returns a structure intended for rendering/committing before the actual export.
3345
     */
3346
    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...
3347
    {
3348
        $ims = [
3349
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3350
            'resources' => [
3351
                'webcontent' => [],
3352
            ],
3353
            'counts' => ['files' => 0, 'folders' => 0],
3354
            'defaultSelection' => [
3355
                'documents' => [],
3356
            ],
3357
        ];
3358
3359
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3360
        $docKey = null;
3361
3362
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3363
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3364
                $docKey = $cand;
3365
                break;
3366
            }
3367
        }
3368
        if (!$docKey) {
3369
            return $ims;
3370
        }
3371
3372
        foreach ($res[$docKey] as $iid => $wrap) {
3373
            if (!\is_object($wrap)) {
3374
                continue;
3375
            }
3376
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3377
3378
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3379
            if ('' === $rawPath) {
3380
                continue;
3381
            }
3382
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3383
3384
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3385
            $isDir = ($fileType === 'folder') || (substr($rawPath, -1) === '/');
3386
3387
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3388
            $ims['resources']['webcontent'][] = [
3389
                'id' => (int) $iid,
3390
                'cc_type' => 'webcontent',
3391
                'title' => $title !== '' ? $title : basename($rel),
3392
                'rel' => $rel,
3393
                'is_dir' => $isDir,
3394
                'would_be_manifest_entry' => !$isDir,
3395
            ];
3396
3397
            if (!$isDir) {
3398
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3399
                $ims['counts']['files']++;
3400
            } else {
3401
                $ims['counts']['folders']++;
3402
            }
3403
        }
3404
3405
        return $ims;
3406
    }
3407
3408
    /**
3409
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3410
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3411
     */
3412
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3413
    {
3414
        $out = [
3415
            'documents' => (array) ($normSel['documents'] ?? []),
3416
            'links'     => (array) ($normSel['links']     ?? []),
3417
            'forums'    => (array) ($normSel['forums']    ?? []),
3418
        ];
3419
3420
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3421
3422
        // Link categories → link IDs
3423
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3424
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3425
            $links   = $res['link'] ?? $res['Link'];
3426
            foreach ($links as $lid => $wrap) {
3427
                if (!\is_object($wrap)) { continue; }
3428
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3429
                $cid = (string) (int) ($e->category_id ?? 0);
3430
                if (isset($selCats[$cid])) { $out['links'][(string)$lid] = true; }
3431
            }
3432
        }
3433
3434
        // Forum categories → forum IDs
3435
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3436
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3437
            $forums  = $res['forum'] ?? $res['Forum'];
3438
            foreach ($forums as $fid => $wrap) {
3439
                if (!\is_object($wrap)) { continue; }
3440
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3441
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3442
                if (isset($selCats[$cid])) { $out['forums'][(string)$fid] = true; }
3443
            }
3444
        }
3445
3446
        return $out;
3447
    }
3448
3449
    /**
3450
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3451
     *
3452
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3453
     *
3454
     * @param array<int,array<string,mixed>> $selected
3455
     * @return string[]
3456
     */
3457
    private function inferToolsFromSelection(array $selected): array
3458
    {
3459
        $has = static fn(string $k): bool =>
3460
            !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3461
3462
        $want = [];
3463
3464
        // documents
3465
        if ($has('document')) {
3466
            $want[] = 'documents';
3467
        }
3468
3469
        // links (categories imply links too)
3470
        if ($has('link') || $has('link_category')) {
3471
            $want[] = 'links';
3472
        }
3473
3474
        // forums (any of the family implies forums)
3475
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3476
            $want[] = 'forums';
3477
        }
3478
3479
        // quizzes / questions
3480
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3481
            $want[] = 'quizzes';
3482
            $want[] = 'quiz_questions';
3483
        }
3484
3485
        // surveys / questions / invitations
3486
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3487
            $want[] = 'surveys';
3488
            $want[] = 'survey_questions';
3489
        }
3490
3491
        // learnpaths
3492
        if ($has('learnpath') || $has('learnpath_category')) {
3493
            $want[] = 'learnpaths';
3494
            $want[] = 'learnpath_category';
3495
        }
3496
3497
        // others
3498
        if ($has('work'))     { $want[] = 'works'; }
3499
        if ($has('glossary')) { $want[] = 'glossary'; }
3500
        if ($has('tool_intro')) { $want[] = 'tool_intro'; }
3501
3502
        // Dedup
3503
        return array_values(array_unique(array_filter($want)));
3504
    }
3505
3506
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3507
    {
3508
        $out = [];
3509
        foreach ($bucket as $id => $obj) {
3510
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3511
            $k1  = (string) $id;
3512
            $k2  = (string) ($ent->source_id ?? $obj->source_id ?? '');
3513
            if (isset($idsMap[$k1]) || ($k2 !== '' && isset($idsMap[$k2]))) {
3514
                $out[$id] = $obj;
3515
            }
3516
        }
3517
        return $out;
3518
    }
3519
3520
    private function bucketKeyCandidates(string $type): array
3521
    {
3522
        $t = $this->normalizeTypeKey($type);
3523
3524
        // Constants (string values) if defined
3525
        $RD  = \defined('RESOURCE_DOCUMENT')       ? (string) RESOURCE_DOCUMENT       : '';
3526
        $RL  = \defined('RESOURCE_LINK')           ? (string) RESOURCE_LINK           : '';
3527
        $RF  = \defined('RESOURCE_FORUM')          ? (string) RESOURCE_FORUM          : '';
3528
        $RFT = \defined('RESOURCE_FORUMTOPIC')     ? (string) RESOURCE_FORUMTOPIC     : '';
3529
        $RFP = \defined('RESOURCE_FORUMPOST')      ? (string) RESOURCE_FORUMPOST      : '';
3530
        $RQ  = \defined('RESOURCE_QUIZ')           ? (string) RESOURCE_QUIZ           : '';
3531
        $RQQ = \defined('RESOURCE_QUIZQUESTION')   ? (string) RESOURCE_QUIZQUESTION   : '';
3532
        $RS  = \defined('RESOURCE_SURVEY')         ? (string) RESOURCE_SURVEY         : '';
3533
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3534
3535
        $map = [
3536
            'document'         => ['document', 'Document', $RD],
3537
            'link'             => ['link', 'Link', $RL],
3538
            'link_category'    => ['link_category', 'Link_Category'],
3539
            'forum'            => ['forum', 'Forum', $RF],
3540
            'forum_category'   => ['forum_category', 'Forum_Category'],
3541
            'forum_topic'      => ['forum_topic', 'thread', $RFT],
3542
            'forum_post'       => ['forum_post', 'post', $RFP],
3543
            'quiz'             => ['quiz', 'Quiz', $RQ],
3544
            'exercise_question'=> ['Exercise_Question', 'exercise_question', $RQQ],
3545
            'survey'           => ['survey', 'Survey', $RS],
3546
            'survey_question'  => ['Survey_Question', 'survey_question', $RSQ],
3547
            'tool_intro'       => ['tool_intro', 'Tool introduction'],
3548
        ];
3549
3550
        $c = $map[$t] ?? [$t, ucfirst($t)];
3551
        return array_values(array_filter($c, static fn($x) => $x !== ''));
3552
    }
3553
3554
    private function findBucketKey(array $res, string $type): ?string
3555
    {
3556
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3557
        return $key !== null ? (string) $key : null;
3558
    }
3559
3560
    private function findBucket(array $res, string $type): array
3561
    {
3562
        $k = $this->findBucketKey($res, $type);
3563
        return ($k !== null && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3564
    }
3565
3566
    /** True if file extension suggests a Moodle backup. */
3567
    private function isMoodleByExt(string $path): bool
3568
    {
3569
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3570
        return in_array($ext, ['mbz','tgz','gz'], true);
3571
    }
3572
3573
    /** Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files. */
3574
    private function zipHasMoodleBackupXml(string $path): bool
3575
    {
3576
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3577
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3578
        if (!in_array($ext, ['zip','mbz'], true)) {
3579
            return false;
3580
        }
3581
        $zip = new \ZipArchive();
3582
        if (true !== ($err = $zip->open($path))) {
3583
            return false;
3584
        }
3585
        $idx = $zip->locateName('moodle_backup.xml', \ZipArchive::FL_NOCASE);
3586
        $zip->close();
3587
        return ($idx !== false);
3588
    }
3589
3590
    /** Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files. */
3591
    private function zipHasCourseInfoDat(string $path): bool
3592
    {
3593
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3594
        if (!in_array($ext, ['zip','mbz'], true)) {
3595
            return false;
3596
        }
3597
        $zip = new \ZipArchive();
3598
        if (true !== ($err = $zip->open($path))) {
3599
            return false;
3600
        }
3601
        // common locations
3602
        foreach (['course_info.dat','course/course_info.dat','backup/course_info.dat'] as $cand) {
3603
            $idx = $zip->locateName($cand, \ZipArchive::FL_NOCASE);
3604
            if ($idx !== false) { $zip->close(); return true; }
3605
        }
3606
        $zip->close();
3607
        return false;
3608
    }
3609
3610
    /**
3611
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3612
     * Throws RuntimeException on failure.
3613
     */
3614
    private function loadMoodleCourseOrFail(string $absPath): object
3615
    {
3616
        if (!class_exists(MoodleImport::class)) {
3617
            throw new \RuntimeException('MoodleImport class not available');
3618
        }
3619
        $importer = new MoodleImport(debug: $this->debug);
3620
3621
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
3622
            throw new \RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
3623
        }
3624
3625
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
3626
3627
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
3628
            throw new \RuntimeException('Moodle backup contains no importable resources');
3629
        }
3630
3631
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3632
        $course->resources['__meta']['import_source'] = 'moodle';
3633
3634
        return $course;
3635
    }
3636
3637
    /**
3638
     * Recursively sanitize an unserialized PHP graph:
3639
     * - Objects are cast to arrays, keys like "\0Class\0prop" become "prop"
3640
     * - Returns arrays/stdClass with only public-like keys
3641
     */
3642
    private function sanitizePhpGraph(mixed $value): mixed
3643
    {
3644
        if (\is_array($value)) {
3645
            $out = [];
3646
            foreach ($value as $k => $v) {
3647
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3648
                $out[$ck] = $this->sanitizePhpGraph($v);
3649
            }
3650
            return $out;
3651
        }
3652
3653
        if (\is_object($value)) {
3654
            $arr = (array) $value;
3655
            $clean = [];
3656
            foreach ($arr as $k => $v) {
3657
                $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k;
3658
                $clean[$ck] = $this->sanitizePhpGraph($v);
3659
            }
3660
            return (object) $clean;
3661
        }
3662
3663
        return $value;
3664
    }
3665
}
3666