Passed
Pull Request — master (#7027)
by
unknown
09:12
created

CourseMaintenanceController::bucketKeyCandidates()   D

Complexity

Conditions 10
Paths 512

Size

Total Lines 32
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 25
c 0
b 0
f 0
nc 512
nop 1
dl 0
loc 32
rs 4.1777

How to fix   Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\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 stdClass;
24
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
25
use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag};
26
use Symfony\Component\Routing\Attribute\Route;
27
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
28
use Symfony\Component\Security\Http\Attribute\IsGranted;
29
use Throwable;
30
31
use const ARRAY_FILTER_USE_BOTH;
32
use const JSON_UNESCAPED_SLASHES;
33
use const JSON_UNESCAPED_UNICODE;
34
use const PATHINFO_EXTENSION;
35
36
#[IsGranted('ROLE_TEACHER')]
37
#[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])]
38
class CourseMaintenanceController extends AbstractController
39
{
40
    /**
41
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
42
     */
43
    private bool $debug = true;
44
45
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
46
    public function importOptions(int $node, Request $req): JsonResponse
47
    {
48
        $this->setDebugFromRequest($req);
49
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
50
51
        return $this->json([
52
            'sources' => ['local', 'server'],
53
            'importOptions' => ['full_backup', 'select_items'],
54
            'sameName' => ['skip', 'rename', 'overwrite'],
55
            'defaults' => [
56
                'importOption' => 'full_backup',
57
                'sameName' => 'rename',
58
                'sameFileNameOption' => 2,
59
            ],
60
        ]);
61
    }
62
63
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
64
    public function importUpload(int $node, Request $req): JsonResponse
65
    {
66
        $this->setDebugFromRequest($req);
67
68
        $file = $req->files->get('file');
69
        if (!$file || !$file->isValid()) {
70
            return $this->json(['error' => 'Invalid upload'], 400);
71
        }
72
73
        $maxBytes = 1024 * 1024 * 512;
74
        if ($file->getSize() > $maxBytes) {
75
            return $this->json(['error' => 'File too large'], 413);
76
        }
77
78
        $allowed = ['zip', 'mbz', 'gz', 'tgz'];
79
        $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
80
        if (!\in_array($ext, $allowed, true)) {
81
            return $this->json(['error' => 'Unsupported file type'], 415);
82
        }
83
84
        $this->logDebug('[importUpload] received', [
85
            'original_name' => $file->getClientOriginalName(),
86
            'size' => $file->getSize(),
87
            'mime' => $file->getClientMimeType(),
88
        ]);
89
90
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
91
        if (false === $backupId) {
92
            $this->logDebug('[importUpload] archive dir not writable');
93
94
            return $this->json(['error' => 'Archive directory is not writable'], 500);
95
        }
96
97
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
98
99
        return $this->json([
100
            'backupId' => $backupId,
101
            'filename' => $file->getClientOriginalName(),
102
        ]);
103
    }
104
105
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
106
    public function importServerPick(int $node, Request $req): JsonResponse
107
    {
108
        $this->setDebugFromRequest($req);
109
        $payload = json_decode($req->getContent() ?: '{}', true);
110
111
        $filename = basename((string) ($payload['filename'] ?? ''));
112
        if ('' === $filename || preg_match('/[\/\\\]/', $filename)) {
113
            return $this->json(['error' => 'Invalid filename'], 400);
114
        }
115
116
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
117
        $realBase = realpath(CourseArchiver::getBackupDir());
118
        $realPath = realpath($path);
119
        if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) {
120
            $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]);
121
122
            return $this->json(['error' => 'File not found'], 404);
123
        }
124
125
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
126
127
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
128
    }
129
130
    #[Route(
131
        '/import/{backupId}/resources',
132
        name: 'import_resources',
133
        requirements: ['backupId' => '.+'],
134
        methods: ['GET']
135
    )]
136
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
137
    {
138
        $this->setDebugFromRequest($req);
139
        $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle'
140
141
        $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
142
143
        $this->logDebug('[importResources] course loaded', [
144
            'has_resources' => \is_array($course->resources ?? null),
145
            'keys' => array_keys((array) ($course->resources ?? [])),
146
        ]);
147
148
        $tree = $this->buildResourceTreeForVue($course);
149
150
        $warnings = [];
151
        if (empty($tree)) {
152
            $warnings[] = 'Backup has no selectable resources.';
153
        }
154
155
        return $this->json([
156
            'tree'     => $tree,
157
            'warnings' => $warnings,
158
            'meta'     => ['import_source' => $course->resources['__meta']['import_source'] ?? null],
159
        ]);
160
    }
161
162
    #[Route(
163
        '/import/{backupId}/restore',
164
        name: 'import_restore',
165
        requirements: ['backupId' => '.+'],
166
        methods: ['POST']
167
    )]
168
    public function importRestore(
169
        int $node,
170
        string $backupId,
171
        Request $req,
172
        EntityManagerInterface $em
173
    ): JsonResponse {
174
        $this->setDebugFromRequest($req);
175
176
        error_log('COURSE_DEBUG: [importRestore] begin -> ' . json_encode([
177
                'node'     => $node,
178
                'backupId' => $backupId,
179
            ], JSON_UNESCAPED_SLASHES));
180
181
        try {
182
            // Disable profiler & SQL logger for performance
183
            if ($this->container->has('profiler')) {
184
                $profiler = $this->container->get('profiler');
185
                if ($profiler instanceof \Symfony\Component\HttpKernel\Profiler\Profiler) {
186
                    $profiler->disable();
187
                }
188
            }
189
            if ($this->container->has('doctrine')) {
190
                $emDoctrine = $this->container->get('doctrine')->getManager();
191
                if ($emDoctrine && $emDoctrine->getConnection()) {
192
                    $emDoctrine->getConnection()->getConfiguration()->setSQLLogger(null);
193
                }
194
            }
195
196
            // Parse payload
197
            $payload = json_decode($req->getContent() ?: '{}', true) ?: [];
198
            $mode               = strtolower((string) ($payload['mode'] ?? 'auto'));         // 'auto' | 'dat' | 'moodle'
199
            $importOption       = (string)  ($payload['importOption'] ?? 'full_backup');      // 'full_backup' | 'select_items'
200
            $sameFileNameOption = (int)     ($payload['sameFileNameOption'] ?? 2);            // 0 skip | 1 overwrite | 2 rename
201
            $selectedResources  = (array)   ($payload['resources'] ?? []);                    // map type -> [ids]
202
            $selectedTypes      = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
203
204
            error_log('COURSE_DEBUG: [importRestore] input -> ' . json_encode([
205
                    'mode'               => $mode,
206
                    'importOption'       => $importOption,
207
                    'sameFileNameOption' => $sameFileNameOption,
208
                    'selectedTypes.count'=> count($selectedTypes),
209
                    'hasResourcesMap'    => !empty($selectedResources),
210
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
211
212
            // Load snapshot (keep same source mode as GET /resources)
213
            $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
214
            if (!\is_object($course) || !\is_array($course->resources ?? null)) {
215
                return $this->json(['error' => 'Backup has no resources'], 400);
216
            }
217
218
            // Quick counts for logging
219
            $counts = [];
220
            foreach ((array) $course->resources as $k => $bag) {
221
                if ($k === '__meta') { continue; }
222
                $counts[$k] = \is_array($bag) ? \count($bag) : 0;
223
            }
224
            error_log('COURSE_DEBUG: [importRestore] snapshot.counts -> ' . json_encode($counts, JSON_UNESCAPED_SLASHES));
225
226
            // Detect source (Moodle vs non-Moodle)
227
            $importSource = strtolower((string) ($course->resources['__meta']['import_source'] ?? $mode));
228
            $isMoodle     = ($importSource === 'moodle');
229
            error_log('COURSE_DEBUG: [importRestore] detected import source -> ' . json_encode([
230
                    'import_source' => $importSource,
231
                    'isMoodle'      => $isMoodle,
232
                ], JSON_UNESCAPED_SLASHES));
233
234
            // Build requested buckets list
235
            if ($importOption === 'select_items') {
236
                if (!empty($selectedResources)) {
237
                    $requested = array_keys(array_filter(
238
                        $selectedResources,
239
                        static fn ($ids) => \is_array($ids) && !empty($ids)
240
                    ));
241
                } elseif (!empty($selectedTypes)) {
242
                    $requested = $selectedTypes;
243
                } else {
244
                    return $this->json(['error' => 'No resources selected'], 400);
245
                }
246
            } else {
247
                // full_backup => take all snapshot keys (except __meta)
248
                $requested = array_keys(array_filter(
249
                    (array) $course->resources,
250
                    static fn ($k) => $k !== '__meta',
251
                    ARRAY_FILTER_USE_KEY
252
                ));
253
            }
254
            // Normalize for case
255
            $requested = array_values(array_unique(array_map(static fn ($k) => strtolower((string) $k), $requested)));
256
            error_log('COURSE_DEBUG: [importRestore] requested -> ' . json_encode($requested, JSON_UNESCAPED_SLASHES));
257
258
            // Non-Moodle path (use CourseRestorer directly)
259
            if (!$isMoodle) {
260
                error_log('COURSE_DEBUG: [importRestore] non-Moodle path -> CourseRestorer');
261
262
                if ($importOption === 'select_items') {
263
                    $filtered = clone $course;
264
                    $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
265
                    foreach ($requested as $rk) {
266
                        if (isset($course->resources[$rk])) {
267
                            $filtered->resources[$rk] = $course->resources[$rk];
268
                        }
269
                    }
270
                    $course = $filtered;
271
                }
272
273
                $restorer = new CourseRestorer($course);
274
                // Restorer understands 0/1/2 for (skip/overwrite/rename)
275
                $restorer->set_file_option($sameFileNameOption);
276
                if (method_exists($restorer, 'setDebug')) {
277
                    $restorer->setDebug($this->debug ?? false);
278
                }
279
280
                $t0 = microtime(true);
281
                $restorer->restore();
282
                $ms = (int) round((microtime(true) - $t0) * 1000);
283
284
                CourseArchiver::cleanBackupDir();
285
286
                $dstId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
287
                error_log('COURSE_DEBUG: [importRestore] non-Moodle DONE ms=' . $ms . ' dstId=' . $dstId);
288
289
                return $this->json([
290
                    'ok'          => true,
291
                    'message'     => 'Import finished',
292
                    'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', $dstId ?: (int) (api_get_course_info()['real_id'] ?? 0)),
293
                ]);
294
            }
295
296
            // Moodle path (documents via MoodleImport, rest via CourseRestorer)
297
            $cacheDir   = (string) $this->getParameter('kernel.cache_dir');
298
            $backupPath = rtrim($cacheDir, '/').'/course_backups/'.$backupId;
299
300
            $stats = $this->restoreMoodle(
301
                $backupPath,
302
                $course,
303
                $requested,
304
                $sameFileNameOption,
305
                $em
306
            );
307
308
            return $this->json([
309
                'ok'          => true,
310
                'message'     => 'Moodle import finished',
311
                'stats'       => $stats,
312
                'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', (int) (api_get_course_info()['real_id'] ?? 0)),
313
            ]);
314
315
        } catch (\Throwable $e) {
316
            error_log('COURSE_DEBUG: [importRestore] ERROR -> ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
317
            return $this->json([
318
                'error'   => 'Restore failed: ' . $e->getMessage(),
319
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
320
            ], 500);
321
        } finally {
322
            // Defensive cleanup
323
            try { CourseArchiver::cleanBackupDir(); } catch (\Throwable) {}
0 ignored issues
show
introduced by
Consider moving this CATCH statement to a new line.
Loading history...
324
        }
325
    }
326
327
    /**
328
     * Single helper for Moodle branch:
329
     * - Restore documents from MBZ (needs ZIP path and MoodleImport).
330
     * - Restore remaining buckets using CourseRestorer over the filtered snapshot.
331
     */
332
    private function restoreMoodle(
333
        string $backupPath,
334
        object $course,
335
        array $requested,
336
        int $sameFileNameOption,
337
        EntityManagerInterface $em
338
    ): array {
339
        $stats = [];
340
        $ci  = api_get_course_info();
341
        $cid = (int) ($ci['real_id'] ?? 0);
342
        $sid = 0;
343
344
        // 1) Documents (if requested)
345
        $wantsDocs = \in_array('document', $requested, true) || \in_array('documents', $requested, true);
346
        if ($wantsDocs) {
347
            $tag = substr(dechex(random_int(0, 0xFFFFFF)), 0, 6);
348
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: begin path=%s', $tag, $backupPath));
349
350
            $importer = new MoodleImport(debug: $this->debug ?? false);
351
352
            $courseForDocs = clone $course;
353
            $courseForDocs->resources = [
354
                '__meta'   => $course->resources['__meta'] ?? [],
355
                'document' => $course->resources['document'] ?? [],
356
            ];
357
358
            $t0 = microtime(true);
359
            if (method_exists($importer, 'restoreDocuments')) {
360
                $res = $importer->restoreDocuments(
361
                    $backupPath,
362
                    $em,
363
                    $cid,
364
                    $sid,
365
                    (int) $sameFileNameOption,
366
                    $courseForDocs
367
                );
368
                $stats['documents'] = $res ?? ['imported' => 0];
369
            } else {
370
                error_log('MBZ['.$tag.'] RESTORE_DOCS: restoreDocuments() not found on importer');
371
                $stats['documents'] = ['imported' => 0, 'notes' => ['restoreDocuments() missing']];
372
            }
373
            $ms = (int) round((microtime(true) - $t0) * 1000);
374
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: end ms=%d', $tag, $ms));
375
        }
376
377
        // 2) Remaining buckets via CourseRestorer (links, forums, announcements, attendance, etc.)
378
        $restRequested = array_values(array_filter($requested, static function ($k) {
379
            $k = strtolower((string) $k);
380
            return $k !== 'document' && $k !== 'documents';
381
        }));
382
        if (empty($restRequested)) {
383
            return $stats;
384
        }
385
386
        $filtered = clone $course;
387
        $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
388
        foreach ($restRequested as $rk) {
389
            if (isset($course->resources[$rk])) {
390
                $filtered->resources[$rk] = $course->resources[$rk];
391
            }
392
        }
393
        // Simple dependency example: include Link_Category when links are requested
394
        if (\in_array('link', $restRequested, true) || \in_array('links', $restRequested, true)) {
395
            if (isset($course->resources['Link_Category'])) {
396
                $filtered->resources['Link_Category'] = $course->resources['Link_Category'];
397
            }
398
        }
399
400
        error_log('COURSE_DEBUG: [restoreMoodle] restorer.keys -> ' . json_encode(array_keys((array) $filtered->resources), JSON_UNESCAPED_SLASHES));
401
402
        $restorer = new CourseRestorer($filtered);
403
        $restorer->set_file_option($sameFileNameOption);
404
        if (method_exists($restorer, 'setDebug')) {
405
            $restorer->setDebug($this->debug ?? false);
406
        }
407
408
        $t1 = microtime(true);
409
        $restorer->restore();
410
        $ms = (int) round((microtime(true) - $t1) * 1000);
411
        error_log('COURSE_DEBUG: [restoreMoodle] restorer DONE ms=' . $ms);
412
413
        $stats['restored_tools'] = array_values(array_filter(
414
            array_keys((array) $filtered->resources),
415
            static fn($k) => $k !== '__meta'
416
        ));
417
418
        return $stats;
419
    }
420
421
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
422
    public function copyOptions(int $node, Request $req): JsonResponse
423
    {
424
        $this->setDebugFromRequest($req);
425
426
        $current = api_get_course_info();
427
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
428
429
        $courses = [];
430
        foreach ($courseList as $c) {
431
            if ((int) $c['real_id'] === (int) $current['real_id']) {
432
                continue;
433
            }
434
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
435
        }
436
437
        return $this->json([
438
            'courses' => $courses,
439
            'defaults' => [
440
                'copyOption' => 'full_copy',
441
                'includeUsers' => false,
442
                'resetDates' => true,
443
                'sameFileNameOption' => 2,
444
            ],
445
        ]);
446
    }
447
448
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
449
    public function copyResources(int $node, Request $req): JsonResponse
450
    {
451
        $this->setDebugFromRequest($req);
452
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
453
        if ('' === $sourceCourseCode) {
454
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
455
        }
456
457
        $cb = new CourseBuilder();
458
        $cb->set_tools_to_build([
459
            'documents',
460
            'forums',
461
            'tool_intro',
462
            'links',
463
            'quizzes',
464
            'quiz_questions',
465
            'assets',
466
            'surveys',
467
            'survey_questions',
468
            'announcement',
469
            'events',
470
            'course_descriptions',
471
            'glossary',
472
            'wiki',
473
            'thematic',
474
            'attendance',
475
            'works',
476
            'gradebook',
477
            'learnpath_category',
478
            'learnpaths',
479
        ]);
480
481
        $course = $cb->build(0, $sourceCourseCode);
482
483
        $tree = $this->buildResourceTreeForVue($course);
484
485
        $warnings = [];
486
        if (empty($tree)) {
487
            $warnings[] = 'Source course has no resources.';
488
        }
489
490
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
491
    }
492
493
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
494
    public function copyExecute(int $node, Request $req): JsonResponse
495
    {
496
        $this->setDebugFromRequest($req);
497
498
        try {
499
            $payload = json_decode($req->getContent() ?: '{}', true);
500
501
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
502
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
503
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
504
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
505
506
            if ('' === $sourceCourseId) {
507
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
508
            }
509
510
            $cb = new CourseBuilder('partial');
511
            $cb->set_tools_to_build([
512
                'documents',
513
                'forums',
514
                'tool_intro',
515
                'links',
516
                'quizzes',
517
                'quiz_questions',
518
                'assets',
519
                'surveys',
520
                'survey_questions',
521
                'announcement',
522
                'events',
523
                'course_descriptions',
524
                'glossary',
525
                'wiki',
526
                'thematic',
527
                'attendance',
528
                'works',
529
                'gradebook',
530
                'learnpath_category',
531
                'learnpaths',
532
            ]);
533
            $legacyCourse = $cb->build(0, $sourceCourseId);
534
535
            if ('select_items' === $copyOption) {
536
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
537
538
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
539
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
540
                }
541
            }
542
543
            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

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

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