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

CourseMaintenanceController::importRestore()   F

Complexity

Conditions 40
Paths 2695

Size

Total Lines 202
Code Lines 121

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 40
eloc 121
nc 2695
nop 4
dl 0
loc 202
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use __PHP_Incomplete_Class;
10
use Chamilo\CoreBundle\Repository\Node\UserRepository;
11
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Capabilities;
12
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Export;
13
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Imscc13Import;
14
use Chamilo\CourseBundle\Component\CourseCopy\Course;
15
use Chamilo\CourseBundle\Component\CourseCopy\CourseArchiver;
16
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
17
use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler;
18
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
19
use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm;
20
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleExport;
21
use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleImport;
22
use CourseManager;
23
use Doctrine\ORM\EntityManagerInterface;
24
use RuntimeException;
25
use stdClass;
26
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
27
use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag};
28
use Symfony\Component\Routing\Attribute\Route;
29
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
30
use Symfony\Component\Security\Http\Attribute\IsGranted;
31
use Throwable;
32
use UnserializeApi;
33
use ZipArchive;
34
35
use const ARRAY_FILTER_USE_BOTH;
36
use const DIRECTORY_SEPARATOR;
37
use const FILTER_VALIDATE_BOOL;
38
use const JSON_PARTIAL_OUTPUT_ON_ERROR;
39
use const JSON_UNESCAPED_SLASHES;
40
use const JSON_UNESCAPED_UNICODE;
41
use const PATHINFO_EXTENSION;
42
43
#[IsGranted('ROLE_TEACHER')]
44
#[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])]
45
class CourseMaintenanceController extends AbstractController
46
{
47
    /**
48
     * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1
49
     */
50
    private bool $debug = true;
51
52
    #[Route('/import/options', name: 'import_options', methods: ['GET'])]
53
    public function importOptions(int $node, Request $req): JsonResponse
54
    {
55
        $this->setDebugFromRequest($req);
56
        $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]);
57
58
        return $this->json([
59
            'sources' => ['local', 'server'],
60
            'importOptions' => ['full_backup', 'select_items'],
61
            'sameName' => ['skip', 'rename', 'overwrite'],
62
            'defaults' => [
63
                'importOption' => 'full_backup',
64
                'sameName' => 'rename',
65
                'sameFileNameOption' => 2,
66
            ],
67
        ]);
68
    }
69
70
    #[Route('/import/upload', name: 'import_upload', methods: ['POST'])]
71
    public function importUpload(int $node, Request $req): JsonResponse
72
    {
73
        $this->setDebugFromRequest($req);
74
75
        $file = $req->files->get('file');
76
        if (!$file || !$file->isValid()) {
77
            return $this->json(['error' => 'Invalid upload'], 400);
78
        }
79
80
        $maxBytes = self::getPhpUploadLimitBytes();
81
        $fileSize = (int) ($file->getSize() ?? 0);
82
        if ($maxBytes > 0 && $fileSize > $maxBytes) {
83
            return $this->json(['error' => 'File too large'], 413);
84
        }
85
86
        $allowed = ['zip', 'mbz', 'gz', 'tgz'];
87
        $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
88
        if (!\in_array($ext, $allowed, true)) {
89
            return $this->json(['error' => 'Unsupported file type'], 415);
90
        }
91
92
        $this->logDebug('[importUpload] received', [
93
            'original_name' => $file->getClientOriginalName(),
94
            'size' => $file->getSize(),
95
            'mime' => $file->getClientMimeType(),
96
        ]);
97
98
        $backupId = CourseArchiver::importUploadedFile($file->getRealPath());
99
        if (false === $backupId) {
100
            $this->logDebug('[importUpload] archive dir not writable');
101
102
            return $this->json(['error' => 'Archive directory is not writable'], 500);
103
        }
104
105
        $this->logDebug('[importUpload] stored', ['backupId' => $backupId]);
106
107
        return $this->json([
108
            'backupId' => $backupId,
109
            'filename' => $file->getClientOriginalName(),
110
        ]);
111
    }
112
113
    #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])]
114
    public function importServerPick(int $node, Request $req): JsonResponse
115
    {
116
        $this->setDebugFromRequest($req);
117
        $payload = json_decode($req->getContent() ?: '{}', true);
118
119
        $filename = basename((string) ($payload['filename'] ?? ''));
120
        if ('' === $filename || preg_match('/[\/\\\]/', $filename)) {
121
            return $this->json(['error' => 'Invalid filename'], 400);
122
        }
123
124
        $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename;
125
        $realBase = realpath(CourseArchiver::getBackupDir());
126
        $realPath = realpath($path);
127
        if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) {
128
            $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]);
129
130
            return $this->json(['error' => 'File not found'], 404);
131
        }
132
133
        $this->logDebug('[importServerPick] ok', ['backupId' => $filename]);
134
135
        return $this->json(['backupId' => $filename, 'filename' => $filename]);
136
    }
137
138
    #[Route(
139
        '/import/{backupId}/resources',
140
        name: 'import_resources',
141
        requirements: ['backupId' => '.+'],
142
        methods: ['GET']
143
    )]
144
    public function importResources(int $node, string $backupId, Request $req): JsonResponse
145
    {
146
        $this->setDebugFromRequest($req);
147
        $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle'
148
149
        $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
150
151
        $this->logDebug('[importResources] course loaded', [
152
            'has_resources' => \is_array($course->resources ?? null),
153
            'keys' => array_keys((array) ($course->resources ?? [])),
154
        ]);
155
156
        $tree = $this->buildResourceTreeForVue($course);
157
158
        $warnings = [];
159
        if (empty($tree)) {
160
            $warnings[] = 'Backup has no selectable resources.';
161
        }
162
163
        return $this->json([
164
            'tree' => $tree,
165
            'warnings' => $warnings,
166
            'meta' => ['import_source' => $course->resources['__meta']['import_source'] ?? null],
167
        ]);
168
    }
169
170
    #[Route(
171
        '/import/{backupId}/restore',
172
        name: 'import_restore',
173
        requirements: ['backupId' => '.+'],
174
        methods: ['POST']
175
    )]
176
    public function importRestore(
177
        int $node,
178
        string $backupId,
179
        Request $req,
180
        EntityManagerInterface $em
181
    ): JsonResponse {
182
        $this->setDebugFromRequest($req);
183
184
        error_log('COURSE_DEBUG: [importRestore] begin -> ' . json_encode([
185
                'node'     => $node,
186
                'backupId' => $backupId,
187
            ], JSON_UNESCAPED_SLASHES));
188
189
        try {
190
            // Disable profiler & SQL logger for performance
191
            if ($this->container->has('profiler')) {
192
                $profiler = $this->container->get('profiler');
193
                if ($profiler instanceof \Symfony\Component\HttpKernel\Profiler\Profiler) {
194
                    $profiler->disable();
195
                }
196
            }
197
            if ($this->container->has('doctrine')) {
198
                $emDoctrine = $this->container->get('doctrine')->getManager();
199
                if ($emDoctrine && $emDoctrine->getConnection()) {
200
                    $emDoctrine->getConnection()->getConfiguration()->setSQLLogger(null);
201
                }
202
            }
203
204
            // Parse payload
205
            $payload = json_decode($req->getContent() ?: '{}', true) ?: [];
206
            $mode               = strtolower((string) ($payload['mode'] ?? 'auto'));         // 'auto' | 'dat' | 'moodle'
207
            $importOption       = (string)  ($payload['importOption'] ?? 'full_backup');      // 'full_backup' | 'select_items'
208
            $sameFileNameOption = (int)     ($payload['sameFileNameOption'] ?? 2);            // 0 skip | 1 overwrite | 2 rename
209
            $selectedResources  = (array)   ($payload['resources'] ?? []);                    // map type -> [ids]
210
            $selectedTypes      = array_map('strval', (array) ($payload['selectedTypes'] ?? []));
211
212
            error_log('COURSE_DEBUG: [importRestore] input -> ' . json_encode([
213
                    'mode'               => $mode,
214
                    'importOption'       => $importOption,
215
                    'sameFileNameOption' => $sameFileNameOption,
216
                    'selectedTypes.count'=> count($selectedTypes),
217
                    'hasResourcesMap'    => !empty($selectedResources),
218
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
219
220
            // Load snapshot (keep same source mode as GET /resources)
221
            $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode);
222
            if (!\is_object($course) || !\is_array($course->resources ?? null)) {
223
                return $this->json(['error' => 'Backup has no resources'], 400);
224
            }
225
226
            // Quick counts for logging
227
            $counts = [];
228
            foreach ((array) $course->resources as $k => $bag) {
229
                if ($k === '__meta') { continue; }
230
                $counts[$k] = \is_array($bag) ? \count($bag) : 0;
231
            }
232
            error_log('COURSE_DEBUG: [importRestore] snapshot.counts -> ' . json_encode($counts, JSON_UNESCAPED_SLASHES));
233
234
            // Detect source (Moodle vs non-Moodle)
235
            $importSource = strtolower((string) ($course->resources['__meta']['import_source'] ?? $mode));
236
            $isMoodle     = ($importSource === 'moodle');
237
            error_log('COURSE_DEBUG: [importRestore] detected import source -> ' . json_encode([
238
                    'import_source' => $importSource,
239
                    'isMoodle'      => $isMoodle,
240
                ], JSON_UNESCAPED_SLASHES));
241
242
            // Build requested buckets list
243
            if ($importOption === 'select_items') {
244
                if (!empty($selectedResources)) {
245
                    $requested = array_keys(array_filter(
246
                        $selectedResources,
247
                        static fn ($ids) => \is_array($ids) && !empty($ids)
248
                    ));
249
                } elseif (!empty($selectedTypes)) {
250
                    $requested = $selectedTypes;
251
                } else {
252
            // Load with same mode to avoid switching source on POST
253
            $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode);
254
            if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
255
                return $this->json(['error' => 'Backup has no resources'], 400);
256
            }
257
258
            $resourcesAll = $course->resources;
259
            $this->logDebug('[importRestore] BEFORE filter keys', array_keys($resourcesAll));
260
261
            // Always hydrate LP dependencies (even in full_backup).
262
            $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll);
263
            $this->logDebug('[importRestore] AFTER hydrate keys', array_keys((array) $course->resources));
264
265
            // Detect source BEFORE any filtering (meta may be dropped by filters)
266
            $importSource = $this->getImportSource($course);
267
            $isMoodle = ('moodle' === $importSource);
268
            $this->logDebug('[importRestore] detected import source', ['import_source' => $importSource, 'isMoodle' => $isMoodle]);
269
270
            if ('select_items' === $importOption) {
271
                if (empty($selectedResources) && !empty($selectedTypes)) {
272
                    $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes);
273
                }
274
275
                $hasAny = false;
276
                foreach ($selectedResources as $ids) {
277
                    if (\is_array($ids) && !empty($ids)) {
278
                        $hasAny = true;
279
280
                        break;
281
                    }
282
                }
283
                if (!$hasAny) {
284
                    return $this->json(['error' => 'No resources selected'], 400);
285
                }
286
            } else {
287
                // full_backup => take all snapshot keys (except __meta)
288
                $requested = array_keys(array_filter(
289
                    (array) $course->resources,
290
                    static fn ($k) => $k !== '__meta',
291
                    ARRAY_FILTER_USE_KEY
292
                ));
293
            }
294
            // Normalize for case
295
            $requested = array_values(array_unique(array_map(static fn ($k) => strtolower((string) $k), $requested)));
296
            error_log('COURSE_DEBUG: [importRestore] requested -> ' . json_encode($requested, JSON_UNESCAPED_SLASHES));
297
298
            // Non-Moodle path (use CourseRestorer directly)
299
            if (!$isMoodle) {
300
                error_log('COURSE_DEBUG: [importRestore] non-Moodle path -> CourseRestorer');
301
302
                if ($importOption === 'select_items') {
303
                    $filtered = clone $course;
304
                    $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
305
                    foreach ($requested as $rk) {
306
                        if (isset($course->resources[$rk])) {
307
                            $filtered->resources[$rk] = $course->resources[$rk];
308
                        }
309
                    }
310
                    $course = $filtered;
311
                }
312
313
                $restorer = new CourseRestorer($course);
314
                // Restorer understands 0/1/2 for (skip/overwrite/rename)
315
                $restorer->set_file_option($sameFileNameOption);
316
                if (method_exists($restorer, 'setDebug')) {
317
                    $restorer->setDebug($this->debug ?? false);
318
                }
319
320
                $t0 = microtime(true);
321
                $restorer->restore();
322
                $ms = (int) round((microtime(true) - $t0) * 1000);
323
324
                CourseArchiver::cleanBackupDir();
325
326
                $dstId = (int) ($restorer->destination_course_info['real_id'] ?? 0);
327
                error_log('COURSE_DEBUG: [importRestore] non-Moodle DONE ms=' . $ms . ' dstId=' . $dstId);
328
329
                return $this->json([
330
                    'ok'          => true,
331
                    'message'     => 'Import finished',
332
                    'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', $dstId ?: (int) (api_get_course_info()['real_id'] ?? 0)),
333
                ]);
334
            }
335
336
            // Moodle path (documents via MoodleImport, rest via CourseRestorer)
337
            $cacheDir   = (string) $this->getParameter('kernel.cache_dir');
338
            $backupPath = rtrim($cacheDir, '/').'/course_backups/'.$backupId;
339
340
            $stats = $this->restoreMoodle(
341
                $backupPath,
342
                $course,
343
                $requested,
344
                $sameFileNameOption,
345
                $em
346
            );
347
348
            return $this->json([
349
                'ok'          => true,
350
                'message'     => 'Moodle import finished',
351
                'stats'       => $stats,
352
                'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', (int) (api_get_course_info()['real_id'] ?? 0)),
353
            ]);
354
355
        } catch (\Throwable $e) {
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_CATCH on line 355 at column 10
Loading history...
356
            error_log('COURSE_DEBUG: [importRestore] ERROR -> ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
357
            return $this->json([
358
                'error'   => 'Restore failed: ' . $e->getMessage(),
359
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
360
            ], 500);
361
        } finally {
362
            // Defensive cleanup
363
            try { CourseArchiver::cleanBackupDir(); } catch (\Throwable) {}
364
        }
365
    }
366
367
    /**
368
     * Single helper for Moodle branch:
369
     * - Restore documents from MBZ (needs ZIP path and MoodleImport).
370
     * - Restore remaining buckets using CourseRestorer over the filtered snapshot.
371
     */
372
    private function restoreMoodle(
373
        string $backupPath,
374
        object $course,
375
        array $requested,
376
        int $sameFileNameOption,
377
        EntityManagerInterface $em
378
    ): array {
379
        $stats = [];
380
        $ci  = api_get_course_info();
381
        $cid = (int) ($ci['real_id'] ?? 0);
382
        $sid = 0;
383
384
        // 1) Documents (if requested)
385
        $wantsDocs = \in_array('document', $requested, true) || \in_array('documents', $requested, true);
386
        if ($wantsDocs) {
387
            $tag = substr(dechex(random_int(0, 0xFFFFFF)), 0, 6);
388
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: begin path=%s', $tag, $backupPath));
389
390
            $importer = new MoodleImport(debug: $this->debug ?? false);
391
392
            $courseForDocs = clone $course;
393
            $courseForDocs->resources = [
394
                '__meta'   => $course->resources['__meta'] ?? [],
395
                'document' => $course->resources['document'] ?? [],
396
            ];
397
398
            $t0 = microtime(true);
399
            if (method_exists($importer, 'restoreDocuments')) {
400
                $res = $importer->restoreDocuments(
401
                    $backupPath,
402
                    $em,
403
                    $cid,
404
                    $sid,
405
                    (int) $sameFileNameOption,
406
                    $courseForDocs
407
                );
408
                $stats['documents'] = $res ?? ['imported' => 0];
409
            } else {
410
                error_log('MBZ['.$tag.'] RESTORE_DOCS: restoreDocuments() not found on importer');
411
                $stats['documents'] = ['imported' => 0, 'notes' => ['restoreDocuments() missing']];
412
            }
413
            $ms = (int) round((microtime(true) - $t0) * 1000);
414
            error_log(sprintf('MBZ[%s] RESTORE_DOCS: end ms=%d', $tag, $ms));
415
        }
416
417
        // 2) Remaining buckets via CourseRestorer (links, forums, announcements, attendance, etc.)
418
        $restRequested = array_values(array_filter($requested, static function ($k) {
419
            $k = strtolower((string) $k);
420
            return $k !== 'document' && $k !== 'documents';
421
        }));
422
        if (empty($restRequested)) {
423
            return $stats;
424
        }
425
426
        $filtered = clone $course;
427
        $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []];
428
        foreach ($restRequested as $rk) {
429
            if (isset($course->resources[$rk])) {
430
                $filtered->resources[$rk] = $course->resources[$rk];
431
            }
432
        }
433
        // Simple dependency example: include Link_Category when links are requested
434
        if (\in_array('link', $restRequested, true) || \in_array('links', $restRequested, true)) {
435
            if (isset($course->resources['Link_Category'])) {
436
                $filtered->resources['Link_Category'] = $course->resources['Link_Category'];
437
            }
438
        }
439
440
        error_log('COURSE_DEBUG: [restoreMoodle] restorer.keys -> ' . json_encode(array_keys((array) $filtered->resources), JSON_UNESCAPED_SLASHES));
441
442
        $restorer = new CourseRestorer($filtered);
443
        $restorer->set_file_option($sameFileNameOption);
444
        if (method_exists($restorer, 'setDebug')) {
445
            $restorer->setDebug($this->debug ?? false);
446
        }
447
448
        $t1 = microtime(true);
449
        $restorer->restore();
450
        $ms = (int) round((microtime(true) - $t1) * 1000);
451
        error_log('COURSE_DEBUG: [restoreMoodle] restorer DONE ms=' . $ms);
452
453
        $stats['restored_tools'] = array_values(array_filter(
454
            array_keys((array) $filtered->resources),
455
            static fn($k) => $k !== '__meta'
456
        ));
457
458
        return $stats;
459
    }
460
461
    #[Route('/copy/options', name: 'copy_options', methods: ['GET'])]
462
    public function copyOptions(int $node, Request $req): JsonResponse
463
    {
464
        $this->setDebugFromRequest($req);
465
466
        $current = api_get_course_info();
467
        $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id());
468
469
        $courses = [];
470
        foreach ($courseList as $c) {
471
            if ((int) $c['real_id'] === (int) $current['real_id']) {
472
                continue;
473
            }
474
            $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']];
475
        }
476
477
        return $this->json([
478
            'courses' => $courses,
479
            'defaults' => [
480
                'copyOption' => 'full_copy',
481
                'includeUsers' => false,
482
                'resetDates' => true,
483
                'sameFileNameOption' => 2,
484
            ],
485
        ]);
486
    }
487
488
    #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])]
489
    public function copyResources(int $node, Request $req): JsonResponse
490
    {
491
        $this->setDebugFromRequest($req);
492
        $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', ''));
493
        if ('' === $sourceCourseCode) {
494
            return $this->json(['error' => 'Missing sourceCourseId'], 400);
495
        }
496
497
        $cb = new CourseBuilder();
498
        $cb->set_tools_to_build([
499
            'documents',
500
            'forums',
501
            'tool_intro',
502
            'links',
503
            'quizzes',
504
            'quiz_questions',
505
            'assets',
506
            'surveys',
507
            'survey_questions',
508
            'announcement',
509
            'events',
510
            'course_descriptions',
511
            'glossary',
512
            'wiki',
513
            'thematic',
514
            'attendance',
515
            'works',
516
            'gradebook',
517
            'learnpath_category',
518
            'learnpaths',
519
        ]);
520
521
        $course = $cb->build(0, $sourceCourseCode);
522
523
        $tree = $this->buildResourceTreeForVue($course);
524
525
        $warnings = [];
526
        if (empty($tree)) {
527
            $warnings[] = 'Source course has no resources.';
528
        }
529
530
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
531
    }
532
533
    #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])]
534
    public function copyExecute(int $node, Request $req): JsonResponse
535
    {
536
        $this->setDebugFromRequest($req);
537
538
        try {
539
            $payload = json_decode($req->getContent() ?: '{}', true);
540
541
            $sourceCourseId = (string) ($payload['sourceCourseId'] ?? '');
542
            $copyOption = (string) ($payload['copyOption'] ?? 'full_copy');
543
            $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2);
544
            $selectedResourcesMap = (array) ($payload['resources'] ?? []);
545
546
            if ('' === $sourceCourseId) {
547
                return $this->json(['error' => 'Missing sourceCourseId'], 400);
548
            }
549
550
            $cb = new CourseBuilder('partial');
551
            $cb->set_tools_to_build([
552
                'documents',
553
                'forums',
554
                'tool_intro',
555
                'links',
556
                'quizzes',
557
                'quiz_questions',
558
                'assets',
559
                'surveys',
560
                'survey_questions',
561
                'announcement',
562
                'events',
563
                'course_descriptions',
564
                'glossary',
565
                'wiki',
566
                'thematic',
567
                'attendance',
568
                'works',
569
                'gradebook',
570
                'learnpath_category',
571
                'learnpaths',
572
            ]);
573
            $legacyCourse = $cb->build(0, $sourceCourseId);
574
575
            if ('select_items' === $copyOption) {
576
                $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap);
577
578
                if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) {
579
                    return $this->json(['error' => 'Selection produced no resources to copy'], 400);
580
                }
581
            }
582
583
            error_log('$legacyCourse :::: '.print_r($legacyCourse, true));
584
585
            $restorer = new CourseRestorer($legacyCourse);
586
            $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption));
587
            if (method_exists($restorer, 'setDebug')) {
588
                $restorer->setDebug($this->debug);
589
            }
590
            $restorer->restore();
591
592
            $dest = api_get_course_info();
593
            $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']);
594
595
            return $this->json([
596
                'ok' => true,
597
                'message' => 'Copy finished',
598
                'redirectUrl' => $redirectUrl,
599
            ]);
600
        } catch (Throwable $e) {
601
            return $this->json([
602
                'error' => 'Copy failed: '.$e->getMessage(),
603
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
604
            ], 500);
605
        }
606
    }
607
608
    #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])]
609
    public function recycleOptions(int $node, Request $req): JsonResponse
610
    {
611
        $this->setDebugFromRequest($req);
612
613
        // current course only
614
        $defaults = [
615
            'recycleOption' => 'select_items', // 'full_recycle' | 'select_items'
616
            'confirmNeeded' => true,           // show code-confirm input when full
617
        ];
618
619
        return $this->json(['defaults' => $defaults]);
620
    }
621
622
    #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])]
623
    public function recycleResources(int $node, Request $req): JsonResponse
624
    {
625
        $this->setDebugFromRequest($req);
626
627
        // Build legacy Course from CURRENT course (not “source”)
628
        $cb = new CourseBuilder();
629
        $cb->set_tools_to_build([
630
            'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys',
631
            'survey_questions', 'announcement', 'events', 'course_descriptions', 'glossary', 'wiki',
632
            'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths',
633
        ]);
634
        $course = $cb->build(0, api_get_course_id());
635
636
        $tree = $this->buildResourceTreeForVue($course);
637
        $warnings = empty($tree) ? ['This course has no resources.'] : [];
638
639
        return $this->json(['tree' => $tree, 'warnings' => $warnings]);
640
    }
641
642
    #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])]
643
    public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse
644
    {
645
        try {
646
            $p = json_decode($req->getContent() ?: '{}', true);
647
            $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items'
648
            $resourcesMap = (array) ($p['resources'] ?? []);
649
            $confirmCode = (string) ($p['confirm'] ?? '');
650
651
            $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items';
652
653
            if ('full_backup' === $type) {
654
                if ($confirmCode !== api_get_course_id()) {
655
                    return $this->json(['error' => 'Course code confirmation mismatch'], 400);
656
                }
657
            } else {
658
                if (empty($resourcesMap)) {
659
                    return $this->json(['error' => 'No resources selected'], 400);
660
                }
661
            }
662
663
            $courseCode = api_get_course_id();
664
            $courseInfo = api_get_course_info($courseCode);
665
            $courseId = (int) ($courseInfo['real_id'] ?? 0);
666
            if ($courseId <= 0) {
667
                return $this->json(['error' => 'Invalid course id'], 400);
668
            }
669
670
            $recycler = new CourseRecycler(
671
                $em,
672
                $courseCode,
673
                $courseId
674
            );
675
676
            $recycler->recycle($type, $resourcesMap);
677
678
            return $this->json([
679
                'ok' => true,
680
                'message' => 'Recycle finished',
681
            ]);
682
        } catch (Throwable $e) {
683
            return $this->json([
684
                'error' => 'Recycle failed: '.$e->getMessage(),
685
            ], 500);
686
        }
687
    }
688
689
    #[Route('/delete', name: 'delete', methods: ['POST'])]
690
    public function deleteCourse(int $node, Request $req): JsonResponse
691
    {
692
        // Basic permission gate (adjust roles to your policy if needed)
693
        if (
694
            !$this->isGranted('ROLE_ADMIN')
695
            && !$this->isGranted('ROLE_TEACHER')
696
            && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
697
        ) {
698
            return $this->json(['error' => 'You are not allowed to delete this course'], 403);
699
        }
700
701
        try {
702
            $payload = json_decode($req->getContent() ?: '{}', true);
703
            $confirm = trim((string) ($payload['confirm'] ?? ''));
704
705
            if ('' === $confirm) {
706
                return $this->json(['error' => 'Missing confirmation value'], 400);
707
            }
708
709
            // Optional flag: also delete orphan documents that belong only to this course
710
            // Accepts 1/0, true/false, "1"/"0"
711
            $deleteDocsRaw = $payload['delete_docs'] ?? 0;
712
            $deleteDocs = filter_var($deleteDocsRaw, FILTER_VALIDATE_BOOL);
713
714
            // Current course
715
            $courseInfo = api_get_course_info();
716
            if (empty($courseInfo)) {
717
                return $this->json(['error' => 'Unable to resolve current course'], 400);
718
            }
719
720
            $officialCode = (string) ($courseInfo['official_code'] ?? '');
721
            $runtimeCode = (string) api_get_course_id(); // often equals official code
722
            $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete
723
724
            if ('' === $sysCode) {
725
                return $this->json(['error' => 'Invalid course system code'], 400);
726
            }
727
728
            // Accept either official_code or api_get_course_id() as confirmation
729
            $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm);
730
            if (!$matches) {
731
                return $this->json(['error' => 'Course code confirmation mismatch'], 400);
732
            }
733
734
            // Legacy delete (removes course data + unregisters members in this course)
735
            // Now with optional orphan-docs deletion flag.
736
            CourseManager::delete_course($sysCode, $deleteDocs);
737
738
            // Best-effort cleanup of legacy course session flags
739
            try {
740
                $ses = $req->getSession();
741
                $ses?->remove('_cid');
742
                $ses?->remove('_real_cid');
743
            } catch (Throwable $e) {
744
                // swallow — not critical
745
            }
746
747
            // Decide where to send the user afterwards
748
            $redirectUrl = '/index.php';
749
750
            return $this->json([
751
                'ok' => true,
752
                'message' => 'Course deleted successfully',
753
                'redirectUrl' => $redirectUrl,
754
            ]);
755
        } catch (Throwable $e) {
756
            return $this->json([
757
                'error' => 'Failed to delete course: '.$e->getMessage(),
758
                'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
759
            ], 500);
760
        }
761
    }
762
763
    #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])]
764
    public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse
765
    {
766
        $defaults = [
767
            'moodleVersion' => '4',
768
            'scope' => 'full',
769
            'admin' => $users->getDefaultAdminForExport(),
770
        ];
771
772
        $tools = [
773
            ['value' => 'documents', 'label' => 'Documents (files & root HTML pages)'],
774
            ['value' => 'links', 'label' => 'Links (URL)'],
775
            ['value' => 'forums', 'label' => 'Forums'],
776
            ['value' => 'quizzes', 'label' => 'Quizzes', 'implies' => ['quiz_questions']],
777
            ['value' => 'surveys', 'label' => 'Surveys', 'implies' => ['survey_questions']],
778
            ['value' => 'works', 'label' => 'Tasks'],
779
            ['value' => 'glossary', 'label' => 'Glossary'],
780
            ['value' => 'learnpaths', 'label' => 'Paths learning'],
781
            ['value' => 'tool_intro', 'label' => 'Course Introduction'],
782
            ['value' => 'course_description', 'label' => 'Course descriptions'],
783
            ['value' => 'attendance', 'label' => 'Attendance'],
784
            ['value' => 'announcement', 'label' => 'Announcements'],
785
            ['value' => 'events', 'label' => 'Calendar events'],
786
            ['value' => 'wiki', 'label' => 'Wiki'],
787
            ['value' => 'thematic', 'label' => 'Thematic'],
788
            ['value' => 'gradebook', 'label' => 'Gradebook'],
789
        ];
790
791
        $defaults['tools'] = array_column($tools, 'value');
792
793
        return $this->json([
794
            'versions' => [
795
                ['value' => '3', 'label' => 'Moodle 3.x'],
796
                ['value' => '4', 'label' => 'Moodle 4.x'],
797
            ],
798
            'tools' => $tools,
799
            'defaults' => $defaults,
800
        ]);
801
    }
802
803
    #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])]
804
    public function moodleExportResources(int $node, Request $req): JsonResponse
805
    {
806
        // Enable/disable debug from request
807
        $this->setDebugFromRequest($req);
808
        $this->logDebug('[moodleExportResources] start', ['node' => $node]);
809
810
        try {
811
            // Normalize incoming tools from query (?tools[]=documents&tools[]=links ...)
812
            $selectedTools = $this->normalizeSelectedTools($req->query->all('tools'));
813
814
            // Default toolset tailored for Moodle export picker:
815
            $defaultToolsForMoodle = [
816
                'documents', 'links', 'forums',
817
                'quizzes', 'quiz_questions',
818
                'surveys', 'survey_questions',
819
                'learnpaths', 'learnpath_category',
820
                'works', 'glossary',
821
                'tool_intro',
822
                'course_descriptions',
823
                'attendance',
824
                'announcement',
825
                'events',
826
                'wiki',
827
                'thematic',
828
                'gradebook',
829
            ];
830
831
            // Use client tools if provided; otherwise our Moodle-safe defaults
832
            $tools = !empty($selectedTools) ? $selectedTools : $defaultToolsForMoodle;
833
834
            // Policy for this endpoint:
835
            //  - Never show gradebook
836
            //  - Always include tool_intro in the picker (harmless if empty)
837
            //$tools = array_values(array_diff($tools, ['gradebook']));
838
            if (!in_array('tool_intro', $tools, true)) {
839
            $tools = array_values(array_diff($tools, ['gradebook']));
840
            if (!\in_array('tool_intro', $tools, true)) {
841
                $tools[] = 'tool_intro';
842
            }
843
844
            $this->logDebug('[moodleExportResources] tools to build', $tools);
845
846
            // Build legacy Course snapshot from CURRENT course (not from a source course)
847
            $cb = new CourseBuilder();
848
            $cb->set_tools_to_build($tools);
849
            $course = $cb->build(0, api_get_course_id());
850
851
            // Build the UI-friendly tree
852
            $tree = $this->buildResourceTreeForVue($course);
853
854
            // Basic warnings for the client
855
            $warnings = empty($tree) ? ['This course has no resources.'] : [];
856
857
            // Some compact debug about the resulting tree
858
            if ($this->debug) {
859
                $this->logDebug(
860
                    '[moodleExportResources] tree summary',
861
                    array_map(
862
                        fn ($g) => [
863
                            'type' => $g['type'] ?? '',
864
                            'title' => $g['title'] ?? '',
865
                            'items' => isset($g['items']) ? \count((array) $g['items']) : null,
866
                            'children' => isset($g['children']) ? \count((array) $g['children']) : null,
867
                        ],
868
                        $tree
869
                    )
870
                );
871
            }
872
873
            return $this->json([
874
                'tree' => $tree,
875
                'warnings' => $warnings,
876
            ]);
877
        } catch (Throwable $e) {
878
            // Defensive error path
879
            $this->logDebug('[moodleExportResources] exception', [
880
                'message' => $e->getMessage(),
881
                'file' => $e->getFile().':'.$e->getLine(),
882
            ]);
883
884
            return $this->json([
885
                'error' => 'Failed to build resource tree for Moodle export.',
886
                'details' => $e->getMessage(),
887
            ], 500);
888
        }
889
    }
890
891
    #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])]
892
    public function moodleExportExecute(int $node, Request $req, UserRepository $users): BinaryFileResponse|JsonResponse
893
    {
894
        $this->setDebugFromRequest($req);
895
896
        $p = json_decode($req->getContent() ?: '{}', true) ?: [];
897
        $moodleVersion = (string) ($p['moodleVersion'] ?? '4');  // "3" | "4"
898
        $scope = (string) ($p['scope'] ?? 'full');       // "full" | "selected"
899
        $adminId = (int) ($p['adminId'] ?? 0);
900
        $adminLogin = trim((string) ($p['adminLogin'] ?? ''));
901
        $adminEmail = trim((string) ($p['adminEmail'] ?? ''));
902
        $selected = \is_array($p['resources'] ?? null) ? (array) $p['resources'] : [];
903
        $toolsInput = \is_array($p['tools'] ?? null) ? (array) $p['tools'] : [];
904
905
        if (!\in_array($moodleVersion, ['3', '4'], true)) {
906
            return $this->json(['error' => 'Unsupported Moodle version'], 400);
907
        }
908
        if ('selected' === $scope && empty($selected)) {
909
            return $this->json(['error' => 'No resources selected'], 400);
910
        }
911
912
        $defaultTools = [
913
            'documents', 'links', 'forums',
914
            'quizzes', 'quiz_questions',
915
            'surveys', 'survey_questions',
916
            'learnpaths', 'learnpath_category',
917
            'works', 'glossary',
918
            'course_descriptions',
919
            'attendance',
920
            'announcement',
921
            'events',
922
            'wiki',
923
            'thematic',
924
            'gradebook',
925
        ];
926
927
        $tools = $this->normalizeSelectedTools($toolsInput);
928
929
        // If scope=selected, merge inferred tools from selection
930
        if ('selected' === $scope) {
931
            $inferred = $this->inferToolsFromSelection($selected);
932
            $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
933
        }
934
935
        // Remove unsupported tools
936
        $clientSentNoTools = empty($toolsInput);
937
        $useDefault = ('full' === $scope && $clientSentNoTools);
938
        $toolsToBuild = $useDefault ? $defaultTools : $tools;
939
940
        // Ensure "tool_intro" is present (append only if missing)
941
        if (!\in_array('tool_intro', $toolsToBuild, true)) {
942
            $toolsToBuild[] = 'tool_intro';
943
        }
944
945
        // Final dedupe/normalize
946
        $toolsToBuild = array_values(array_unique($toolsToBuild));
947
948
        $this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild);
949
950
        if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
951
            $adm = $users->getDefaultAdminForExport();
952
            $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
953
            $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
954
            $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? '[email protected]');
955
        }
956
957
        $courseId = api_get_course_id();
958
        if (empty($courseId)) {
959
            return $this->json(['error' => 'No active course context'], 400);
960
        }
961
962
        $cb = new CourseBuilder();
963
        $cb->set_tools_to_build($toolsToBuild);
964
        $course = $cb->build(0, $courseId);
965
966
        if ('selected' === $scope) {
967
            $course = $this->filterLegacyCourseBySelection($course, $selected);
968
            if (empty($course->resources) || !\is_array($course->resources)) {
969
                return $this->json(['error' => 'Selection produced no resources to export'], 400);
970
            }
971
        }
972
973
        try {
974
            // === Export to Moodle MBZ ===
975
            $selectionMode = ('selected' === $scope);
976
            $exporter = new MoodleExport($course, $selectionMode);
977
            $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);
978
979
            $exportDir = 'moodle_export_'.date('Ymd_His');
980
            $versionNum = ('3' === $moodleVersion) ? 3 : 4;
981
982
            $this->logDebug('[moodleExportExecute] starting exporter', [
983
                'courseId' => $courseId,
984
                'exportDir' => $exportDir,
985
                'versionNum' => $versionNum,
986
                'selection' => $selectionMode,
987
                'scope' => $scope,
988
            ]);
989
990
            $mbzPath = $exporter->export($courseId, $exportDir, $versionNum);
991
992
            if (!\is_string($mbzPath) || '' === $mbzPath || !is_file($mbzPath)) {
993
                return $this->json(['error' => 'Moodle export failed: artifact not found'], 500);
994
            }
995
996
            // Build download response
997
            $resp = new BinaryFileResponse($mbzPath);
998
            $resp->setContentDisposition(
999
                ResponseHeaderBag::DISPOSITION_ATTACHMENT,
1000
                basename($mbzPath)
1001
            );
1002
            $resp->headers->set('X-Moodle-Version', (string) $versionNum);
1003
            $resp->headers->set('X-Export-Scope', $scope);
1004
            $resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0');
1005
1006
            return $resp;
1007
        } catch (Throwable $e) {
1008
            $this->logDebug('[moodleExportExecute] exception', [
1009
                'message' => $e->getMessage(),
1010
                'code' => (int) $e->getCode(),
1011
            ]);
1012
1013
            return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
1014
        }
1015
    }
1016
1017
    /**
1018
     * Normalize tool list to supported ones and add implied dependencies.
1019
     *
1020
     * @param array<int,string>|null $tools
1021
     *
1022
     * @return string[]
1023
     */
1024
    private function normalizeSelectedTools(?array $tools): array
1025
    {
1026
        // Single list of supported tool buckets (must match CourseBuilder/exporters)
1027
        $all = [
1028
            'documents','links','quizzes','quiz_questions','surveys','survey_questions',
1029
            'announcement','events','course_descriptions','glossary','wiki','thematic',
1030
            'attendance','works','gradebook','learnpath_category','learnpaths','tool_intro','forums',
1031
            'documents', 'links', 'quizzes', 'quiz_questions', 'surveys', 'survey_questions',
1032
            'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic',
1033
            'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro', 'forums',
1034
        ];
1035
1036
        // Implied dependencies
1037
        $deps = [
1038
            'quizzes' => ['quiz_questions'],
1039
            'surveys' => ['survey_questions'],
1040
            'learnpaths' => ['learnpath_category'],
1041
        ];
1042
1043
        $sel = \is_array($tools) ? array_values(array_intersect($tools, $all)) : [];
1044
1045
        foreach ($sel as $t) {
1046
            foreach ($deps[$t] ?? [] as $d) {
1047
                $sel[] = $d;
1048
            }
1049
        }
1050
1051
        // Unique and preserve a sane order based on $all
1052
        $sel = array_values(array_unique($sel));
1053
        usort($sel, static function ($a, $b) use ($all) {
1054
            return array_search($a, $all, true) <=> array_search($b, $all, true);
1055
        });
1056
1057
        return $sel;
1058
    }
1059
1060
    #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])]
1061
    public function cc13ExportOptions(int $node, Request $req): JsonResponse
1062
    {
1063
        $this->setDebugFromRequest($req);
1064
1065
        return $this->json([
1066
            'defaults' => ['scope' => 'full'],
1067
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
1068
            'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.',
1069
        ]);
1070
    }
1071
1072
    #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])]
1073
    public function cc13ExportResources(int $node, Request $req): JsonResponse
1074
    {
1075
        $this->setDebugFromRequest($req);
1076
1077
        $cb = new CourseBuilder();
1078
        $cb->set_tools_to_build(['documents', 'links', 'forums']);
1079
        $course = $cb->build(0, api_get_course_id());
1080
1081
        $treeAll = $this->buildResourceTreeForVue($course);
1082
        $tree = Cc13Capabilities::filterTree($treeAll);
1083
1084
        // Count exportables using "items"
1085
        $exportableCount = 0;
1086
        foreach ($tree as $group) {
1087
            if (empty($group['items']) || !\is_array($group['items'])) {
1088
                continue;
1089
            }
1090
1091
            if (($group['type'] ?? '') === 'forum') {
1092
                foreach ($group['items'] as $cat) {
1093
                    foreach (($cat['items'] ?? []) as $forumNode) {
1094
                        if (($forumNode['type'] ?? '') === 'forum') {
1095
                            $exportableCount++;
1096
                        }
1097
                    }
1098
                }
1099
            } else {
1100
                $exportableCount += \count($group['items'] ?? []);
1101
            }
1102
        }
1103
1104
        $warnings = [];
1105
        if (0 === $exportableCount) {
1106
            $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).';
1107
        }
1108
1109
        return $this->json([
1110
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum']
1111
            'tree' => $tree,
1112
            'preview' => ['counts' => ['total' => $exportableCount]],
1113
            'warnings' => $warnings,
1114
        ]);
1115
    }
1116
1117
    #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])]
1118
    public function cc13ExportExecute(int $node, Request $req): JsonResponse
1119
    {
1120
        $payload = json_decode((string) $req->getContent(), true) ?: [];
1121
        // If the client sent "resources", treat as selected even if scope says "full".
1122
        $scope = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full'));
1123
        $selected = (array) ($payload['resources'] ?? []);
1124
1125
        // Normalize selection structure (documents/links/forums/…)
1126
        $normSel = Cc13Capabilities::filterSelection($selected);
1127
1128
        // Builder setup
1129
        $tools = ['documents', 'links', 'forums'];
1130
        $cb = new CourseBuilder();
1131
1132
        $selectionMode = false;
1133
1134
        try {
1135
            /** @var Course|null $courseFull */
1136
            $courseFull = null;
1137
1138
            if ('selected' === $scope) {
1139
                // Build a full snapshot first to expand any category-only selections.
1140
                $cbFull = new CourseBuilder();
1141
                $cbFull->set_tools_to_build($tools);
1142
                $courseFull = $cbFull->build(0, api_get_course_id());
1143
1144
                $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel);
1145
1146
                // Build per-tool ID map for CourseBuilder
1147
                $map = [];
1148
                if (!empty($expanded['documents'])) {
1149
                    $map['documents'] = array_map('intval', array_keys($expanded['documents']));
1150
                }
1151
                if (!empty($expanded['links'])) {
1152
                    $map['links'] = array_map('intval', array_keys($expanded['links']));
1153
                }
1154
                if (!empty($expanded['forums'])) {
1155
                    $map['forums'] = array_map('intval', array_keys($expanded['forums']));
1156
                }
1157
1158
                if (empty($map)) {
1159
                    return $this->json(['error' => 'Please select at least one resource.'], 400);
1160
                }
1161
1162
                $cb->set_tools_to_build($tools);
1163
                $cb->set_tools_specific_id_list($map);
1164
                $selectionMode = true;
1165
            } else {
1166
                $cb->set_tools_to_build($tools);
1167
            }
1168
1169
            $course = $cb->build(0, api_get_course_id());
1170
1171
            // Safety net: if selection mode, ensure resources are filtered
1172
            if ($selectionMode) {
1173
                // Convert to the expected structure for filterCourseResources()
1174
                $safeSelected = [
1175
                    'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),
1176
                    'links' => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),
1177
                    'forums' => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),
1178
                ];
1179
                // Also include expansions from categories
1180
                $fullSnapshot = $courseFull ?: $course;
1181
                $expandedAll = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
1182
                foreach (['documents', 'links', 'forums'] as $k) {
1183
                    if (!isset($expandedAll[$k])) {
1184
                        continue;
1185
                    }
1186
1187
                    foreach (array_keys($expandedAll[$k]) as $idStr) {
1188
                        $safeSelected[$k][(int) $idStr] = true;
1189
                    }
1190
                }
1191
1192
                $this->filterCourseResources($course, $safeSelected);
1193
                if (empty($course->resources) || !\is_array($course->resources)) {
1194
                    return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400);
1195
                }
1196
            }
1197
1198
            $exporter = new Cc13Export($course, $selectionMode, /* debug */ false);
1199
            $imsccPath = $exporter->export(api_get_course_id());
1200
            $fileName = basename($imsccPath);
1201
1202
            $downloadUrl = $this->generateUrl(
1203
                'cm_cc13_export_download',
1204
                ['node' => $node],
1205
                UrlGeneratorInterface::ABSOLUTE_URL
1206
            ).'?file='.rawurlencode($fileName);
1207
1208
            return $this->json([
1209
                'ok' => true,
1210
                'file' => $fileName,
1211
                'downloadUrl' => $downloadUrl,
1212
                'message' => 'Export finished.',
1213
            ]);
1214
        } catch (RuntimeException $e) {
1215
            if (false !== stripos($e->getMessage(), 'Nothing to export')) {
1216
                return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400);
1217
            }
1218
1219
            return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500);
1220
        }
1221
    }
1222
1223
    #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])]
1224
    public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse
1225
    {
1226
        // Validate the filename we will serve
1227
        $file = basename((string) $req->query->get('file', ''));
1228
        // Example pattern: ABC123_cc13_20251017_195455.imscc
1229
        if ('' === $file || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) {
1230
            return $this->json(['error' => 'Invalid file'], 400);
1231
        }
1232
1233
        $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file;
1234
        if (!is_file($abs)) {
1235
            return $this->json(['error' => 'File not found'], 404);
1236
        }
1237
1238
        // Stream file to the browser
1239
        $resp = new BinaryFileResponse($abs);
1240
        $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file);
1241
        $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc');
1242
1243
        return $resp;
1244
    }
1245
1246
    #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])]
1247
    public function cc13Import(int $node, Request $req): JsonResponse
1248
    {
1249
        $this->setDebugFromRequest($req);
1250
1251
        try {
1252
            $file = $req->files->get('file');
1253
            if (!$file || !$file->isValid()) {
1254
                return $this->json(['error' => 'Missing or invalid upload.'], 400);
1255
            }
1256
1257
            $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION));
1258
            if (!\in_array($ext, ['imscc', 'zip'], true)) {
1259
                return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415);
1260
            }
1261
1262
            // Move to a temp file
1263
            $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.
1264
                'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext;
1265
            $file->move(\dirname($tmpZip), basename($tmpZip));
1266
1267
            // Extract
1268
            $extractDir = Imscc13Import::unzip($tmpZip);
1269
1270
            // Detect and validate format
1271
            $format = Imscc13Import::detectFormat($extractDir);
1272
            if (Imscc13Import::FORMAT_IMSCC13 !== $format) {
1273
                Imscc13Import::rrmdir($extractDir);
1274
                @unlink($tmpZip);
1275
1276
                return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400);
1277
            }
1278
1279
            // Execute import (creates Chamilo resources)
1280
            $importer = new Imscc13Import();
1281
            $importer->execute($extractDir);
1282
1283
            // Cleanup
1284
            Imscc13Import::rrmdir($extractDir);
1285
            @unlink($tmpZip);
1286
1287
            return $this->json([
1288
                'ok' => true,
1289
                'message' => 'CC 1.3 import completed successfully.',
1290
            ]);
1291
        } catch (Throwable $e) {
1292
            return $this->json([
1293
                'error' => 'CC 1.3 import failed: '.$e->getMessage(),
1294
            ], 500);
1295
        }
1296
    }
1297
1298
    #[Route(
1299
        '/import/{backupId}/diagnose',
1300
        name: 'import_diagnose',
1301
        requirements: ['backupId' => '.+'],
1302
        methods: ['GET']
1303
    )]
1304
    public function importDiagnose(int $node, string $backupId, Request $req): JsonResponse
1305
    {
1306
        $this->setDebugFromRequest($req);
1307
        $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]);
1308
1309
        try {
1310
            // Resolve absolute path of the uploaded/selected backup
1311
            $path = $this->resolveBackupPath($backupId);
1312
            if (!is_file($path)) {
1313
                return $this->json(['error' => 'Backup file not found', 'path' => $path], 404);
1314
            }
1315
1316
            // Read course_info.dat bytes from ZIP
1317
            $ci = $this->readCourseInfoFromZip($path);
1318
            if (empty($ci['ok'])) {
1319
                $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci);
1320
1321
                return $this->json([
1322
                    'meta' => [
1323
                        'backupId' => $backupId,
1324
                        'path' => $path,
1325
                    ],
1326
                    'zip' => [
1327
                        'error' => $ci['error'] ?? 'unknown error',
1328
                        'zip_list_sample' => $ci['zip_list_sample'] ?? [],
1329
                        'num_files' => $ci['num_files'] ?? null,
1330
                    ],
1331
                ], 200);
1332
            }
1333
1334
            $raw = (string) $ci['data'];
1335
            $size = (int) ($ci['size'] ?? \strlen($raw));
1336
            $md5 = md5($raw);
1337
1338
            // Detect & decode content
1339
            $probe = $this->decodeCourseInfo($raw);
1340
1341
            // Build a tiny scan snapshot
1342
            $scan = [
1343
                'has_graph' => false,
1344
                'resources_keys' => [],
1345
                'note' => 'No graph parsed',
1346
            ];
1347
1348
            if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) {
1349
                /** @var object $course */
1350
                $course = $probe['value'];
1351
                $scan['has_graph'] = true;
1352
                $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources))
1353
                    ? array_keys($course->resources)
1354
                    : [];
1355
                $scan['note'] = 'Parsed PHP serialized graph';
1356
            } elseif (!empty($probe['is_json']) && \is_array($probe['json_preview'])) {
1357
                $jp = $probe['json_preview'];
1358
                $scan['has_graph'] = true;
1359
                $scan['resources_keys'] = (isset($jp['resources']) && \is_array($jp['resources']))
1360
                    ? array_keys($jp['resources'])
1361
                    : [];
1362
                $scan['note'] = 'Parsed JSON document';
1363
            }
1364
1365
            $probeOut = $probe;
1366
            unset($probeOut['value'], $probeOut['decoded']);
1367
1368
            $out = [
1369
                'meta' => [
1370
                    'backupId' => $backupId,
1371
                    'path' => $path,
1372
                    'node' => $node,
1373
                ],
1374
                'zip' => [
1375
                    'name' => $ci['name'] ?? null,
1376
                    'index' => $ci['index'] ?? null,
1377
                ],
1378
                'course_info_dat' => [
1379
                    'size_bytes' => $size,
1380
                    'md5' => $md5,
1381
                ],
1382
                'probe' => $probeOut,
1383
                'scan' => $scan,
1384
            ];
1385
1386
            $this->logDebug('[importDiagnose] done', [
1387
                'encoding' => $probeOut['encoding'] ?? null,
1388
                'has_graph' => $scan['has_graph'],
1389
                'resources_keys' => $scan['resources_keys'],
1390
            ]);
1391
1392
            return $this->json($out);
1393
        } catch (Throwable $e) {
1394
            $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]);
1395
1396
            return $this->json([
1397
                'error' => 'Diagnosis failed: '.$e->getMessage(),
1398
            ], 500);
1399
        }
1400
    }
1401
1402
    /**
1403
     * Try to detect and decode course_info.dat content.
1404
     * Hardened: preprocess typed-prop numeric strings and register legacy aliases
1405
     * before attempting unserialize. Falls back to relaxed mode to avoid typed
1406
     * property crashes during diagnosis.
1407
     */
1408
    private function decodeCourseInfo(string $raw): array
1409
    {
1410
        $r = [
1411
            'encoding' => 'raw',
1412
            'decoded_len' => \strlen($raw),
1413
            'magic_hex' => bin2hex(substr($raw, 0, 8)),
1414
            'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
1415
            'steps' => [],
1416
            'decoded' => null,
1417
            'is_serialized' => false,
1418
            'is_json' => false,
1419
            'json_preview' => null,
1420
        ];
1421
1422
        $isJson = static function (string $s): bool {
1423
            $t = ltrim($s);
1424
1425
            return '' !== $t && ('{' === $t[0] || '[' === $t[0]);
1426
        };
1427
1428
        // Centralized tolerant unserialize with typed-props preprocessing
1429
        $tryUnserializeTolerant = function (string $s, string $label) use (&$r) {
1430
            $ok = false;
1431
            $val = null;
1432
            $err = null;
1433
            $relaxed = false;
1434
1435
            // Ensure legacy aliases and coerce numeric strings before unserialize
1436
            try {
1437
                CourseArchiver::ensureLegacyAliases();
1438
            } catch (Throwable) { /* ignore */
1439
            }
1440
1441
            try {
1442
                $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s);
1443
            } catch (Throwable) { /* ignore */
1444
            }
1445
1446
            // Strict mode
1447
            set_error_handler(static function (): void {});
1448
1449
            try {
1450
                $val = @unserialize($s, ['allowed_classes' => true]);
1451
                $ok = (false !== $val) || ('b:0;' === trim($s));
1452
            } catch (Throwable $e) {
1453
                $err = $e->getMessage();
1454
                $ok = false;
1455
            } finally {
1456
                restore_error_handler();
1457
            }
1458
            $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err];
1459
1460
            // Relaxed fallback (no class instantiation) + deincomplete to stdClass
1461
            if (!$ok) {
1462
                $err2 = null;
1463
                set_error_handler(static function (): void {});
1464
1465
                try {
1466
                    $tmp = @unserialize($s, ['allowed_classes' => false]);
1467
                    if (false !== $tmp || 'b:0;' === trim($s)) {
1468
                        $val = $this->deincomplete($tmp);
1469
                        $ok = true;
1470
                        $relaxed = true;
1471
                        $err = null;
1472
                    }
1473
                } catch (Throwable $e2) {
1474
                    $err2 = $e2->getMessage();
1475
                } finally {
1476
                    restore_error_handler();
1477
                }
1478
                $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2];
1479
            }
1480
1481
            if ($ok) {
1482
                $r['is_serialized'] = true;
1483
                $r['decoded'] = null; // keep payload minimal
1484
                $r['used_relaxed'] = $relaxed;
1485
1486
                return $val;
1487
            }
1488
1489
            return null;
1490
        };
1491
1492
        // 0) JSON as-is?
1493
        if ($isJson($raw)) {
1494
            $r['encoding'] = 'json';
1495
            $r['is_json'] = true;
1496
            $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1497
1498
            return $r;
1499
        }
1500
1501
        // Direct PHP serialize (strict then relaxed, after preprocessing)
1502
        if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) {
1503
            $r['encoding'] = 'php-serialize';
1504
1505
            return $r + ['value' => $u];
1506
        }
1507
1508
        // GZIP
1509
        if (0 === strncmp($raw, "\x1F\x8B", 2)) {
1510
            $dec = @gzdecode($raw);
1511
            $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec];
1512
            if (false !== $dec) {
1513
                if ($isJson($dec)) {
1514
                    $r['encoding'] = 'gzip+json';
1515
                    $r['is_json'] = true;
1516
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1517
1518
                    return $r;
1519
                }
1520
                if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) {
1521
                    $r['encoding'] = 'gzip+php-serialize';
1522
1523
                    return $r + ['value' => $u];
1524
                }
1525
            }
1526
        }
1527
1528
        // ZLIB/DEFLATE
1529
        $z2 = substr($raw, 0, 2);
1530
        if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) {
1531
            $dec = @gzuncompress($raw);
1532
            $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec];
1533
            if (false !== $dec) {
1534
                if ($isJson($dec)) {
1535
                    $r['encoding'] = 'zlib+json';
1536
                    $r['is_json'] = true;
1537
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1538
1539
                    return $r;
1540
                }
1541
                if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) {
1542
                    $r['encoding'] = 'zlib+php-serialize';
1543
1544
                    return $r + ['value' => $u];
1545
                }
1546
            }
1547
            $dec2 = @gzinflate($raw);
1548
            $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2];
1549
            if (false !== $dec2) {
1550
                if ($isJson($dec2)) {
1551
                    $r['encoding'] = 'deflate+json';
1552
                    $r['is_json'] = true;
1553
                    $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1554
1555
                    return $r;
1556
                }
1557
                if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) {
1558
                    $r['encoding'] = 'deflate+php-serialize';
1559
1560
                    return $r + ['value' => $u];
1561
                }
1562
            }
1563
        }
1564
1565
        // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...'))
1566
        if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) {
1567
            $dec = base64_decode($raw, true);
1568
            $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec];
1569
            if (false !== $dec) {
1570
                if ($isJson($dec)) {
1571
                    $r['encoding'] = 'base64(json)';
1572
                    $r['is_json'] = true;
1573
                    $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR);
1574
1575
                    return $r;
1576
                }
1577
                if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) {
1578
                    $r['encoding'] = 'base64(php-serialize)';
1579
1580
                    return $r + ['value' => $u];
1581
                }
1582
                // base64 + gzip nested
1583
                if (0 === strncmp($dec, "\x1F\x8B", 2)) {
1584
                    $dec2 = @gzdecode($dec);
1585
                    $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2];
1586
                    if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) {
1587
                        $r['encoding'] = 'base64(gzip+php-serialize)';
1588
1589
                        return $r + ['value' => $u];
1590
                    }
1591
                }
1592
            }
1593
        }
1594
1595
        // Nested ZIP?
1596
        if (0 === strncmp($raw, "PK\x03\x04", 4)) {
1597
            $r['encoding'] = 'nested-zip';
1598
        }
1599
1600
        return $r;
1601
    }
1602
1603
    /**
1604
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
1605
     * Also traverses arrays and objects (diagnostics-only).
1606
     */
1607
    private function deincomplete(mixed $v): mixed
1608
    {
1609
        if ($v instanceof __PHP_Incomplete_Class) {
1610
            $o = new stdClass();
1611
            foreach (get_object_vars($v) as $k => $vv) {
1612
                $o->{$k} = $this->deincomplete($vv);
1613
            }
1614
1615
            return $o;
1616
        }
1617
        if (\is_array($v)) {
1618
            foreach ($v as $k => $vv) {
1619
                $v[$k] = $this->deincomplete($vv);
1620
            }
1621
1622
            return $v;
1623
        }
1624
        if (\is_object($v)) {
1625
            foreach (get_object_vars($v) as $k => $vv) {
1626
                $v->{$k} = $this->deincomplete($vv);
1627
            }
1628
1629
            return $v;
1630
        }
1631
1632
        return $v;
1633
    }
1634
1635
    /**
1636
     * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive).
1637
     * Also tries common subpaths, e.g., "course/course_info.dat".
1638
     */
1639
    private function readCourseInfoFromZip(string $zipPath): array
1640
    {
1641
        $candidates = [
1642
            'course_info.dat',
1643
            'course/course_info.dat',
1644
            'backup/course_info.dat',
1645
        ];
1646
1647
        $zip = new ZipArchive();
1648
        if (true !== ($err = $zip->open($zipPath))) {
1649
            return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')'];
1650
        }
1651
1652
        // First: direct name lookup (case-insensitive)
1653
        $foundIdx = null;
1654
        $foundName = null;
1655
1656
        for ($i = 0; $i < $zip->numFiles; $i++) {
1657
            $st = $zip->statIndex($i);
1658
            if (!$st || !isset($st['name'])) {
1659
                continue;
1660
            }
1661
            $name = (string) $st['name'];
1662
            $base = strtolower(basename($name));
1663
            if ('course_info.dat' === $base) {
1664
                $foundIdx = $i;
1665
                $foundName = $name;
1666
1667
                break;
1668
            }
1669
        }
1670
1671
        // Try specific candidate paths if direct scan failed
1672
        if (null === $foundIdx) {
1673
            foreach ($candidates as $cand) {
1674
                $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
1675
                if (false !== $idx) {
1676
                    $foundIdx = $idx;
1677
                    $foundName = $zip->getNameIndex($idx);
1678
1679
                    break;
1680
                }
1681
            }
1682
        }
1683
1684
        if (null === $foundIdx) {
1685
            // Build a short listing for debugging
1686
            $list = [];
1687
            $limit = min($zip->numFiles, 200);
1688
            for ($i = 0; $i < $limit; $i++) {
1689
                $n = $zip->getNameIndex($i);
1690
                if (false !== $n) {
1691
                    $list[] = $n;
1692
                }
1693
            }
1694
            $zip->close();
1695
1696
            return [
1697
                'ok' => false,
1698
                'error' => 'course_info.dat not found in archive',
1699
                'zip_list_sample' => $list,
1700
                'num_files' => $zip->numFiles,
1701
            ];
1702
        }
1703
1704
        $stat = $zip->statIndex($foundIdx);
1705
        $size = (int) ($stat['size'] ?? 0);
1706
        $fp = $zip->getStream($foundName);
1707
        if (!$fp) {
1708
            $zip->close();
1709
1710
            return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)'];
1711
        }
1712
1713
        $data = stream_get_contents($fp);
1714
        fclose($fp);
1715
        $zip->close();
1716
1717
        if (!\is_string($data)) {
1718
            return ['ok' => false, 'error' => 'Failed to read course_info.dat contents'];
1719
        }
1720
1721
        return [
1722
            'ok' => true,
1723
            'name' => $foundName,
1724
            'index' => $foundIdx,
1725
            'size' => $size,
1726
            'data' => $data,
1727
        ];
1728
    }
1729
1730
    /**
1731
     * Build a Vue-friendly tree from legacy Course.
1732
     */
1733
    private function buildResourceTreeForVue(object $course): array
1734
    {
1735
        if ($this->debug) {
1736
            $this->logDebug('[buildResourceTreeForVue] start');
1737
        }
1738
1739
        $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources)
1740
            ? $course->resources
1741
            : [];
1742
1743
        $legacyTitles = [];
1744
        if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) {
1745
            /** @var array<string,string> $legacyTitles */
1746
            $legacyTitles = CourseSelectForm::getResourceTitleList();
1747
        }
1748
        $fallbackTitles = $this->getDefaultTypeTitles();
1749
        $skipTypes = $this->getSkipTypeKeys();
1750
1751
        $tree = [];
1752
1753
        // Documents block
1754
        if (!empty($resources['document']) && \is_array($resources['document'])) {
1755
            $docs = $resources['document'];
1756
1757
            $normalize = function (string $rawPath, string $title, string $filetype): string {
1758
                $p = trim($rawPath, '/');
1759
                $p = (string) preg_replace('~^(?:document/)+~i', '', $p);
1760
                $parts = array_values(array_filter(explode('/', $p), 'strlen'));
1761
1762
                // host
1763
                if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) {
1764
                    array_shift($parts);
1765
                }
1766
                // course-code
1767
                if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) {
1768
                    array_shift($parts);
1769
                }
1770
1771
                $clean = implode('/', $parts);
1772
                if ('' === $clean && 'folder' !== $filetype) {
1773
                    $clean = $title;
1774
                }
1775
                if ('folder' === $filetype) {
1776
                    $clean = rtrim($clean, '/').'/';
1777
                }
1778
1779
                return $clean;
1780
            };
1781
1782
            $folderIdByPath = [];
1783
            foreach ($docs as $obj) {
1784
                if (!\is_object($obj)) {
1785
                    continue;
1786
                }
1787
                $ft = (string) ($obj->filetype ?? $obj->file_type ?? '');
1788
                if ('folder' !== $ft) {
1789
                    continue;
1790
                }
1791
                $rel = $normalize((string) $obj->path, (string) $obj->title, $ft);
1792
                $key = rtrim($rel, '/');
1793
                if ('' !== $key) {
1794
                    $folderIdByPath[strtolower($key)] = (int) $obj->source_id;
1795
                }
1796
            }
1797
1798
            $docRoot = [];
1799
            $findChild = static function (array &$children, string $label): ?int {
1800
                foreach ($children as $i => $n) {
1801
                    if ((string) ($n['label'] ?? '') === $label) {
1802
                        return $i;
1803
                    }
1804
                }
1805
1806
                return null;
1807
            };
1808
1809
            foreach ($docs as $obj) {
1810
                if (!\is_object($obj)) {
1811
                    continue;
1812
                }
1813
1814
                $title = (string) $obj->title;
1815
                $filetype = (string) ($obj->filetype ?? $obj->file_type ?? '');
1816
                $rel = $normalize((string) $obj->path, $title, $filetype);
1817
                $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen'));
1818
1819
                $cursor = &$docRoot;
1820
                $soFar = '';
1821
                $total = \count($parts);
1822
1823
                for ($i = 0; $i < $total; $i++) {
1824
                    $seg = $parts[$i];
1825
                    $isLast = ($i === $total - 1);
1826
                    $isFolder = (!$isLast) || ('folder' === $filetype);
1827
1828
                    $soFar = ltrim($soFar.'/'.$seg, '/');
1829
                    $label = $seg.($isFolder ? '/' : '');
1830
1831
                    $idx = $findChild($cursor, $label);
1832
                    if (null === $idx) {
1833
                        if ($isFolder) {
1834
                            $folderId = $folderIdByPath[strtolower($soFar)] ?? null;
1835
                            $node = [
1836
                                'id' => $folderId ?? ('dir:'.$soFar),
1837
                                'label' => $label,
1838
                                'selectable' => true,
1839
                                'children' => [],
1840
                            ];
1841
                        } else {
1842
                            $node = [
1843
                                'id' => (int) $obj->source_id,
1844
                                'label' => $label,
1845
                                'selectable' => true,
1846
                            ];
1847
                        }
1848
                        $cursor[] = $node;
1849
                        $idx = \count($cursor) - 1;
1850
                    }
1851
1852
                    if ($isFolder) {
1853
                        if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) {
1854
                            $cursor[$idx]['children'] = [];
1855
                        }
1856
                        $cursor = &$cursor[$idx]['children'];
1857
                    }
1858
                }
1859
            }
1860
1861
            $sortTree = null;
1862
            $sortTree = function (array &$nodes) use (&$sortTree): void {
1863
                usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
1864
                foreach ($nodes as &$n) {
1865
                    if (isset($n['children']) && \is_array($n['children'])) {
1866
                        $sortTree($n['children']);
1867
                    }
1868
                }
1869
            };
1870
            $sortTree($docRoot);
1871
1872
            $tree[] = [
1873
                'type' => 'document',
1874
                'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'),
1875
                'children' => $docRoot,
1876
            ];
1877
1878
            $skipTypes['document'] = true;
1879
        }
1880
1881
        // Forums block
1882
        $hasForumData =
1883
            (!empty($resources['forum']) || !empty($resources['Forum']))
1884
            || (!empty($resources['forum_category']) || !empty($resources['Forum_Category']))
1885
            || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic']))
1886
            || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post']));
1887
1888
        if ($hasForumData) {
1889
            $tree[] = $this->buildForumTreeForVue(
1890
                $course,
1891
                $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums')
1892
            );
1893
            $skipTypes['forum'] = true;
1894
            $skipTypes['forum_category'] = true;
1895
            $skipTypes['forum_topic'] = true;
1896
            $skipTypes['forum_post'] = true;
1897
            $skipTypes['thread'] = true;
1898
            $skipTypes['post'] = true;
1899
        }
1900
1901
        // Links block (Category → Link)
1902
        $hasLinkData =
1903
            (!empty($resources['link']) || !empty($resources['Link']))
1904
            || (!empty($resources['link_category']) || !empty($resources['Link_Category']));
1905
1906
        if ($hasLinkData) {
1907
            $tree[] = $this->buildLinkTreeForVue(
1908
                $course,
1909
                $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links')
1910
            );
1911
            $skipTypes['link'] = true;
1912
            $skipTypes['link_category'] = true;
1913
        }
1914
1915
        foreach ($resources as $rawType => $items) {
1916
            if (!\is_array($items) || empty($items)) {
1917
                continue;
1918
            }
1919
            $typeKey = $this->normalizeTypeKey($rawType);
1920
            if (isset($skipTypes[$typeKey])) {
1921
                continue;
1922
            }
1923
1924
            $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey));
1925
            $group = [
1926
                'type' => $typeKey,
1927
                'title' => (string) $groupTitle,
1928
                'items' => [],
1929
            ];
1930
1931
            if ('gradebook' === $typeKey) {
1932
                $group['items'][] = [
1933
                    'id' => 'all',
1934
                    'label' => 'Gradebook (all)',
1935
                    'extra' => new stdClass(),
1936
                    'selectable' => true,
1937
                ];
1938
                $tree[] = $group;
1939
1940
                continue;
1941
            }
1942
1943
            foreach ($items as $id => $obj) {
1944
                if (!\is_object($obj)) {
1945
                    continue;
1946
                }
1947
1948
                $idKey = is_numeric($id) ? (int) $id : (string) $id;
1949
                if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) {
1950
                    continue;
1951
                }
1952
1953
                if (!$this->isSelectableItem($typeKey, $obj)) {
1954
                    continue;
1955
                }
1956
1957
                $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0);
1958
1959
                if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) {
1960
                    $label = $idKey;
1961
                }
1962
1963
                $extra = $this->buildExtra($typeKey, $obj);
1964
1965
                $group['items'][] = [
1966
                    'id' => $idKey,
1967
                    'label' => $label,
1968
                    'extra' => $extra ?: new stdClass(),
1969
                    'selectable' => true,
1970
                ];
1971
            }
1972
1973
            if (!empty($group['items'])) {
1974
                usort(
1975
                    $group['items'],
1976
                    static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])
1977
                );
1978
                $tree[] = $group;
1979
            }
1980
        }
1981
1982
        // Preferred order
1983
        $preferredOrder = [
1984
            'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link',
1985
            'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'events', 'tool_intro', 'gradebook',
1986
        ];
1987
        usort($tree, static function ($a, $b) use ($preferredOrder) {
1988
            $ia = array_search($a['type'], $preferredOrder, true);
1989
            $ib = array_search($b['type'], $preferredOrder, true);
1990
            if (false !== $ia && false !== $ib) {
1991
                return $ia <=> $ib;
1992
            }
1993
            if (false !== $ia) {
1994
                return -1;
1995
            }
1996
            if (false !== $ib) {
1997
                return 1;
1998
            }
1999
2000
            return strcasecmp($a['title'], $b['title']);
2001
        });
2002
2003
        if ($this->debug) {
2004
            $this->logDebug(
2005
                '[buildResourceTreeForVue] end groups',
2006
                array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree)
2007
            );
2008
        }
2009
2010
        return $tree;
2011
    }
2012
2013
    /**
2014
     * Build forum tree (Category → Forum → Topic) for the UI.
2015
     * Uses only "items" (no "children") and sets UI hints (has_children, item_count).
2016
     */
2017
    private function buildForumTreeForVue(object $course, string $groupTitle): array
2018
    {
2019
        $this->logDebug('[buildForumTreeForVue] start');
2020
2021
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
2022
2023
        // Buckets (defensive: accept legacy casings / aliases)
2024
        $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? [];
2025
        $forumRaw = $res['forum'] ?? $res['Forum'] ?? [];
2026
        $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []);
2027
        $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []);
2028
2029
        $this->logDebug('[buildForumTreeForVue] raw counts', [
2030
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
2031
            'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0,
2032
            'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0,
2033
            'posts' => \is_array($postRaw) ? \count($postRaw) : 0,
2034
        ]);
2035
2036
        // Quick classifiers (defensive)
2037
        $isForum = function (object $o): bool {
2038
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2039
            if (isset($e->forum_title) && \is_string($e->forum_title)) {
2040
                return true;
2041
            }
2042
            if (isset($e->default_view) || isset($e->allow_anonymous)) {
2043
                return true;
2044
            }
2045
            if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) {
2046
                return true;
2047
            }
2048
2049
            return false;
2050
        };
2051
        $isTopic = function (object $o): bool {
2052
            $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o;
2053
            if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) {
2054
                return true;
2055
            }
2056
            if (isset($e->forum_id) && !isset($e->forum_title)) {
2057
                return true;
2058
            }
2059
2060
            return false;
2061
        };
2062
        $getForumCategoryId = function (object $forum): int {
2063
            $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum;
2064
            $cid = (int) ($e->forum_category ?? 0);
2065
            if ($cid <= 0) {
2066
                $cid = (int) ($e->forum_category_id ?? 0);
2067
            }
2068
            if ($cid <= 0) {
2069
                $cid = (int) ($e->category_id ?? 0);
2070
            }
2071
2072
            return $cid;
2073
        };
2074
2075
        // Build categories
2076
        $cats = [];
2077
        foreach ($catRaw as $id => $obj) {
2078
            $id = (int) $id;
2079
            if ($id <= 0 || !\is_object($obj)) {
2080
                continue;
2081
            }
2082
            $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id);
2083
            $cats[$id] = [
2084
                'id' => $id,
2085
                'type' => 'forum_category',
2086
                'label' => ('' !== $label ? $label : 'Category #'.$id).'/',
2087
                'selectable' => true,
2088
                'items' => [],
2089
                'has_children' => false,
2090
                'item_count' => 0,
2091
                'extra' => ['filetype' => 'folder'],
2092
            ];
2093
        }
2094
        // Virtual "Uncategorized"
2095
        $uncatKey = -9999;
2096
        if (!isset($cats[$uncatKey])) {
2097
            $cats[$uncatKey] = [
2098
                'id' => $uncatKey,
2099
                'type' => 'forum_category',
2100
                'label' => 'Uncategorized/',
2101
                'selectable' => true,
2102
                'items' => [],
2103
                '_virtual' => true,
2104
                'has_children' => false,
2105
                'item_count' => 0,
2106
                'extra' => ['filetype' => 'folder'],
2107
            ];
2108
        }
2109
2110
        // Forums
2111
        $forums = [];
2112
        foreach ($forumRaw as $id => $obj) {
2113
            $id = (int) $id;
2114
            if ($id <= 0 || !\is_object($obj)) {
2115
                continue;
2116
            }
2117
            if (!$isForum($obj)) {
2118
                $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]);
2119
2120
                continue;
2121
            }
2122
            $forums[$id] = $this->objectEntity($obj);
2123
        }
2124
2125
        // Topics (+ post counts)
2126
        $topics = [];
2127
        $postCountByTopic = [];
2128
        foreach ($topicRaw as $id => $obj) {
2129
            $id = (int) $id;
2130
            if ($id <= 0 || !\is_object($obj)) {
2131
                continue;
2132
            }
2133
            if ($isForum($obj) && !$isTopic($obj)) {
2134
                $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]);
2135
2136
                continue;
2137
            }
2138
            if (!$isTopic($obj)) {
2139
                continue;
2140
            }
2141
            $topics[$id] = $this->objectEntity($obj);
2142
        }
2143
        foreach ($postRaw as $id => $obj) {
2144
            $id = (int) $id;
2145
            if ($id <= 0 || !\is_object($obj)) {
2146
                continue;
2147
            }
2148
            $e = $this->objectEntity($obj);
2149
            $tid = (int) ($e->thread_id ?? 0);
2150
            if ($tid > 0) {
2151
                $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1;
2152
            }
2153
        }
2154
2155
        // Attach topics to forums and forums to categories
2156
        foreach ($forums as $fid => $f) {
2157
            $catId = $getForumCategoryId($f);
2158
            if (!isset($cats[$catId])) {
2159
                $catId = $uncatKey;
2160
            }
2161
2162
            $forumNode = [
2163
                'id' => $fid,
2164
                'type' => 'forum',
2165
                'label' => $this->resolveItemLabel('forum', $f, $fid),
2166
                'extra' => $this->buildExtra('forum', $f) ?: new stdClass(),
2167
                'selectable' => true,
2168
                'items' => [],
2169
                // UI hints
2170
                'has_children' => false,
2171
                'item_count' => 0,
2172
                'ui_depth' => 2,
2173
            ];
2174
2175
            foreach ($topics as $tid => $t) {
2176
                if ((int) ($t->forum_id ?? 0) !== $fid) {
2177
                    continue;
2178
                }
2179
2180
                $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? '');
2181
                $date = (string) ($t->thread_date ?? '');
2182
                $nPosts = (int) ($postCountByTopic[$tid] ?? 0);
2183
2184
                $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid);
2185
                $meta = [];
2186
                if ('' !== $author) {
2187
                    $meta[] = $author;
2188
                }
2189
                if ('' !== $date) {
2190
                    $meta[] = $date;
2191
                }
2192
                if ($meta) {
2193
                    $topicLabel .= ' ('.implode(', ', $meta).')';
2194
                }
2195
                if ($nPosts > 0) {
2196
                    $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's');
2197
                }
2198
2199
                $forumNode['items'][] = [
2200
                    'id' => $tid,
2201
                    'type' => 'forum_topic',
2202
                    'label' => $topicLabel,
2203
                    'extra' => new stdClass(),
2204
                    'selectable' => true,
2205
                    'ui_depth' => 3,
2206
                    'item_count' => 0,
2207
                ];
2208
            }
2209
2210
            if (!empty($forumNode['items'])) {
2211
                usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2212
                $forumNode['has_children'] = true;
2213
                $forumNode['item_count'] = \count($forumNode['items']);
2214
            }
2215
2216
            $cats[$catId]['items'][] = $forumNode;
2217
        }
2218
2219
        // Remove empty virtual category; sort forums inside each category
2220
        $catNodes = array_values(array_filter($cats, static function ($c) {
2221
            if (!empty($c['_virtual']) && empty($c['items'])) {
2222
                return false;
2223
            }
2224
2225
            return true;
2226
        }));
2227
2228
        // Flatten stray forums (defensive) and finalize UI hints
2229
        foreach ($catNodes as &$cat) {
2230
            if (!empty($cat['items'])) {
2231
                $lift = [];
2232
                foreach ($cat['items'] as &$forumNode) {
2233
                    if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) {
2234
                        continue;
2235
                    }
2236
                    $keep = [];
2237
                    foreach ($forumNode['items'] as $child) {
2238
                        if (($child['type'] ?? '') === 'forum') {
2239
                            $lift[] = $child;
2240
                            $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [
2241
                                'parent_forum_id' => $forumNode['id'] ?? null,
2242
                                'lifted_forum_id' => $child['id'] ?? null,
2243
                                'cat_id' => $cat['id'] ?? null,
2244
                            ]);
2245
                        } else {
2246
                            $keep[] = $child;
2247
                        }
2248
                    }
2249
                    $forumNode['items'] = $keep;
2250
                    $forumNode['has_children'] = !empty($keep);
2251
                    $forumNode['item_count'] = \count($keep);
2252
                }
2253
                unset($forumNode);
2254
2255
                foreach ($lift as $n) {
2256
                    $cat['items'][] = $n;
2257
                }
2258
                usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
2259
            }
2260
2261
            // UI hints for category
2262
            $cat['has_children'] = !empty($cat['items']);
2263
            $cat['item_count'] = \count($cat['items'] ?? []);
2264
        }
2265
        unset($cat);
2266
2267
        $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]);
2268
2269
        return [
2270
            'type' => 'forum',
2271
            'title' => $groupTitle,
2272
            'items' => $catNodes,
2273
        ];
2274
    }
2275
2276
    /**
2277
     * Canonicalizes a resource/bucket key used anywhere in the flow (UI type, snapshot key, etc.)
2278
     * Keep this small and stable; only map well-known aliases to the canonical snapshot keys we expect.
2279
     */
2280
    private function normalizeTypeKey(string $key): string
2281
    {
2282
        $k = strtolower(trim($key));
2283
2284
        // Documents
2285
        if (in_array($k, ['document','documents'], true)) {
2286
            return 'document';
2287
        }
2288
2289
        // Links
2290
        if (in_array($k, ['link','links'], true)) {
2291
            return 'link';
2292
        }
2293
        if (in_array($k, ['link_category','linkcategory','link_categories'], true)) {
2294
            return 'link_category';
2295
        }
2296
2297
        // Forums
2298
        if ($k === 'forum_category' || $k === 'forumcategory') {
2299
            return 'Forum_Category';
2300
        }
2301
        if ($k === 'forums') {
2302
            return 'forum';
2303
        }
2304
2305
        // Announcements / News
2306
        if (in_array($k, ['announcement','announcements','news'], true)) {
2307
            return 'announcement';
2308
        }
2309
2310
        // Attendance
2311
        if (in_array($k, ['attendance','attendances'], true)) {
2312
            return 'attendance';
2313
        }
2314
2315
        // Course descriptions
2316
        if (in_array($k, ['course_description','course_descriptions','description','descriptions'], true)) {
2317
            return 'course_descriptions';
2318
        }
2319
2320
        // Events / Calendar
2321
        if (in_array($k, ['event','events','calendar','calendar_event','calendar_events'], true)) {
2322
            return 'events';
2323
        }
2324
2325
        // Learnpaths
2326
        if ($k === 'learnpaths') {
2327
            return 'learnpath';
2328
        }
2329
2330
        // Quizzes
2331
        if (in_array($k, ['quiz','quizzes'], true)) {
2332
            return 'quiz';
2333
        }
2334
2335
        // Default: return as-is
2336
        return $k;
2337
    }
2338
2339
    /**
2340
     * Keys to skip as top-level groups in UI.
2341
     *
2342
     * @return array<string,bool>
2343
     */
2344
    private function getSkipTypeKeys(): array
2345
    {
2346
        return [
2347
            'forum_category' => true,
2348
            'forum_topic' => true,
2349
            'forum_post' => true,
2350
            'thread' => true,
2351
            'post' => true,
2352
            'exercise_question' => true,
2353
            'survey_question' => true,
2354
            'survey_invitation' => true,
2355
            'session_course' => true,
2356
            'scorm' => true,
2357
            'asset' => true,
2358
            'link_category' => true,
2359
        ];
2360
    }
2361
2362
    /**
2363
     * Default labels for groups.
2364
     *
2365
     * @return array<string,string>
2366
     */
2367
    private function getDefaultTypeTitles(): array
2368
    {
2369
        return [
2370
            'announcement' => 'announcement',
2371
            'document' => 'Documents',
2372
            'glossary' => 'Glossaries',
2373
            'calendar_event' => 'Calendar events',
2374
            'event' => 'Calendar events',
2375
            'events' => 'Calendar events',
2376
            'link' => 'Links',
2377
            'course_description' => 'Course descriptions',
2378
            'learnpath' => 'Parcours',
2379
            'learnpath_category' => 'Learning path categories',
2380
            'forum' => 'Forums',
2381
            'forum_category' => 'Forum categories',
2382
            'quiz' => 'Exercices',
2383
            'test_category' => 'Test categories',
2384
            'wiki' => 'Wikis',
2385
            'thematic' => 'Thematics',
2386
            'attendance' => 'Attendances',
2387
            'work' => 'Works',
2388
            'session_course' => 'Session courses',
2389
            'gradebook' => 'Gradebook',
2390
            'scorm' => 'SCORM packages',
2391
            'survey' => 'Surveys',
2392
            'survey_question' => 'Survey questions',
2393
            'survey_invitation' => 'Survey invitations',
2394
            'asset' => 'Assets',
2395
            'tool_intro' => 'Tool introductions',
2396
        ];
2397
    }
2398
2399
    /**
2400
     * Decide if an item is selectable (UI).
2401
     */
2402
    private function isSelectableItem(string $type, object $obj): bool
2403
    {
2404
        if ($type === 'announcement') {
2405
            // Require at least a non-empty title
2406
            return isset($obj->title) && trim((string)$obj->title) !== '';
2407
        }
2408
2409
        if ('document' === $type) {
2410
            return true;
2411
        }
2412
2413
        return true;
2414
    }
2415
2416
    /**
2417
     * Resolve label for an item with fallbacks.
2418
     */
2419
    private function resolveItemLabel(string $type, object $obj, int $fallbackId): string
2420
    {
2421
        if ($type === 'announcement') {
2422
            return (string)($obj->title ?? ("Announcement #".$obj->iid));
2423
        }
2424
2425
        $entity = $this->objectEntity($obj);
2426
2427
        foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) {
2428
            if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) {
2429
                return trim((string) $entity->{$k});
2430
            }
2431
        }
2432
2433
        if (isset($obj->params) && \is_array($obj->params)) {
2434
            foreach (['title', 'name', 'subject', 'display', 'description'] as $k) {
2435
                if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) {
2436
                    return $obj->params[$k];
2437
                }
2438
            }
2439
        }
2440
2441
        switch ($type) {
2442
            case 'events':
2443
                $title = trim((string)($entity->title ?? $entity->name ?? $entity->subject ?? ''));
2444
                if ($title !== '') { return $title; }
2445
2446
                return '#'.$fallbackId;
2447
            case 'document':
2448
                $raw = (string) ($entity->path ?? $obj->path ?? '');
2449
                if ('' !== $raw) {
2450
                    $rel = ltrim($raw, '/');
2451
                    $rel = preg_replace('~^document/?~', '', $rel);
2452
                    $fileType = (string) ($entity->file_type ?? $obj->file_type ?? '');
2453
                    if ('folder' === $fileType) {
2454
                        $rel = rtrim($rel, '/').'/';
2455
                    }
2456
2457
                    return '' !== $rel ? $rel : basename($raw);
2458
                }
2459
2460
                if (!empty($obj->title)) {
2461
                    return (string) $obj->title;
2462
                }
2463
2464
                break;
2465
2466
            case 'course_description':
2467
                if (!empty($obj->title)) {
2468
                    return (string) $obj->title;
2469
                }
2470
                $t = (int) ($obj->description_type ?? 0);
2471
                $names = [
2472
                    1 => 'Description',
2473
                    2 => 'Objectives',
2474
                    3 => 'Topics',
2475
                    4 => 'Methodology',
2476
                    5 => 'Course material',
2477
                    6 => 'Resources',
2478
                    7 => 'Assessment',
2479
                    8 => 'Custom',
2480
                ];
2481
2482
                return $names[$t] ?? ('#'.$fallbackId);
2483
2484
            case 'announcement':
2485
                if (!empty($obj->title)) {
2486
                    return (string) $obj->title;
2487
                }
2488
2489
                break;
2490
2491
            case 'forum':
2492
                if (!empty($entity->forum_title)) {
2493
                    return (string) $entity->forum_title;
2494
                }
2495
2496
                break;
2497
2498
            case 'forum_category':
2499
                if (!empty($entity->cat_title)) {
2500
                    return (string) $entity->cat_title;
2501
                }
2502
2503
                break;
2504
2505
            case 'link':
2506
                if (!empty($obj->title)) {
2507
                    return (string) $obj->title;
2508
                }
2509
                if (!empty($obj->url)) {
2510
                    return (string) $obj->url;
2511
                }
2512
2513
                break;
2514
2515
            case 'survey':
2516
                if (!empty($obj->title)) {
2517
                    return trim((string) $obj->title);
2518
                }
2519
2520
                break;
2521
2522
            case 'learnpath':
2523
                if (!empty($obj->name)) {
2524
                    return (string) $obj->name;
2525
                }
2526
2527
                break;
2528
2529
            case 'thematic':
2530
                if (isset($obj->params['title']) && \is_string($obj->params['title'])) {
2531
                    return (string) $obj->params['title'];
2532
                }
2533
2534
                break;
2535
2536
            case 'quiz':
2537
                if (!empty($entity->title)) {
2538
                    return (string) $entity->title;
2539
                }
2540
2541
                break;
2542
2543
            case 'forum_topic':
2544
                if (!empty($entity->thread_title)) {
2545
                    return (string) $entity->thread_title;
2546
                }
2547
2548
                break;
2549
        }
2550
2551
        return '#'.$fallbackId;
2552
    }
2553
2554
    /**
2555
     * Extract wrapped entity (->obj) or the object itself.
2556
     */
2557
    private function objectEntity(object $resource): object
2558
    {
2559
        if (isset($resource->obj) && \is_object($resource->obj)) {
2560
            return $resource->obj;
2561
        }
2562
2563
        return $resource;
2564
    }
2565
2566
    /**
2567
     * Extra payload per item for UI (optional).
2568
     */
2569
    private function buildExtra(string $type, object $obj): array
2570
    {
2571
        $extra = [];
2572
2573
        $get = static function (object $o, string $k, $default = null) {
2574
            return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default;
2575
        };
2576
2577
        switch ($type) {
2578
            case 'document':
2579
                $extra['path'] = (string) ($get($obj, 'path', '') ?? '');
2580
                $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? '');
2581
                $extra['size'] = (string) ($get($obj, 'size', '') ?? '');
2582
2583
                break;
2584
2585
            case 'link':
2586
                $extra['url'] = (string) ($get($obj, 'url', '') ?? '');
2587
                $extra['target'] = (string) ($get($obj, 'target', '') ?? '');
2588
2589
                break;
2590
2591
            case 'forum':
2592
                $entity = $this->objectEntity($obj);
2593
                $extra['category_id'] = (string) ($entity->forum_category ?? '');
2594
                $extra['default_view'] = (string) ($entity->default_view ?? '');
2595
2596
                break;
2597
2598
            case 'learnpath':
2599
                $extra['name'] = (string) ($get($obj, 'name', '') ?? '');
2600
                $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) {
2601
                    return [
2602
                        'id' => (int) ($i['id'] ?? 0),
2603
                        'title' => (string) ($i['title'] ?? ''),
2604
                        'type' => (string) ($i['item_type'] ?? ''),
2605
                        'path' => (string) ($i['path'] ?? ''),
2606
                    ];
2607
                }, $obj->items) : [];
2608
2609
                break;
2610
2611
            case 'thematic':
2612
                if (isset($obj->params) && \is_array($obj->params)) {
2613
                    $extra['active'] = (string) ($obj->params['active'] ?? '');
2614
                }
2615
2616
                break;
2617
2618
            case 'quiz':
2619
                $entity = $this->objectEntity($obj);
2620
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2621
                    ? array_map('intval', $entity->question_ids)
2622
                    : [];
2623
2624
                break;
2625
2626
            case 'survey':
2627
                $entity = $this->objectEntity($obj);
2628
                $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids)
2629
                    ? array_map('intval', $entity->question_ids)
2630
                    : [];
2631
2632
                break;
2633
            case 'events':
2634
                $entity = $this->objectEntity($obj);
2635
                $extra['start']    = (string)($entity->start ?? $entity->start_date ?? $entity->timestart ?? '');
2636
                $extra['end']      = (string)($entity->end ?? $entity->end_date ?? $entity->timeend ?? '');
2637
                $extra['all_day']  = (string)($entity->all_day ?? $entity->allday ?? '');
2638
                $extra['location'] = (string)($entity->location ?? '');
2639
                break;
2640
        }
2641
2642
        return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v));
2643
    }
2644
2645
    /**
2646
     * Get first existing key from candidates.
2647
     */
2648
    private function firstExistingKey(array $orig, array $candidates): ?string
2649
    {
2650
        foreach ($candidates as $k) {
2651
            if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) {
2652
                return $k;
2653
            }
2654
        }
2655
2656
        return null;
2657
    }
2658
2659
    /**
2660
     * Filter legacy Course by UI selections (and pull dependencies).
2661
     *
2662
     * @param array $selected [type => [id => true]]
2663
     */
2664
    private function filterLegacyCourseBySelection(object $course, array $selected): object
2665
    {
2666
        // Sanitize incoming selection (frontend sometimes sends synthetic groups)
2667
        $selected = array_filter($selected, 'is_array');
2668
        unset($selected['undefined']);
2669
2670
        $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]);
2671
2672
        if (empty($course->resources) || !\is_array($course->resources)) {
2673
            $this->logDebug('[filterSelection] course has no resources');
2674
2675
            return $course;
2676
        }
2677
2678
        /** @var array<string,mixed> $orig */
2679
        $orig = $course->resources;
2680
2681
        // Preserve meta buckets (keys that start with "__")
2682
        $__metaBuckets = [];
2683
        foreach ($orig as $k => $v) {
2684
            if (\is_string($k) && str_starts_with($k, '__')) {
2685
                $__metaBuckets[$k] = $v;
2686
            }
2687
        }
2688
2689
        $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : [];
2690
2691
        // ---------- Forums flow ----------
2692
        if (!empty($selected['forum'])) {
2693
            $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true);
2694
            if (!empty($selForums)) {
2695
                // tolerant lookups
2696
                $forums = $this->findBucket($orig, 'forum');
2697
                $threads = $this->findBucket($orig, 'forum_topic');
2698
                $posts = $this->findBucket($orig, 'forum_post');
2699
2700
                $catsToKeep = [];
2701
2702
                foreach ($forums as $fid => $f) {
2703
                    if (!isset($selForums[(string) $fid])) {
2704
                        continue;
2705
                    }
2706
                    $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f;
2707
                    $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
2708
                    if ($cid > 0) {
2709
                        $catsToKeep[$cid] = true;
2710
                    }
2711
                }
2712
2713
                $threadToKeep = [];
2714
                foreach ($threads as $tid => $t) {
2715
                    $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t;
2716
                    if (isset($selForums[(string) ($e->forum_id ?? '')])) {
2717
                        $threadToKeep[(int) $tid] = true;
2718
                    }
2719
                }
2720
2721
                $postToKeep = [];
2722
                foreach ($posts as $pid => $p) {
2723
                    $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p;
2724
                    if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) {
2725
                        $postToKeep[(int) $pid] = true;
2726
                    }
2727
                }
2728
2729
                $out = [];
2730
                foreach ($selected as $type => $ids) {
2731
                    if (!\is_array($ids) || empty($ids)) {
2732
                        continue;
2733
                    }
2734
                    $bucket = $this->findBucket($orig, (string) $type);
2735
                    $key = $this->findBucketKey($orig, (string) $type);
2736
                    if (null !== $key && !empty($bucket)) {
2737
                        $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2738
                        $out[$key] = $this->intersectBucketByIds($bucket, $idsMap);
2739
                    }
2740
                }
2741
2742
                $forumCat = $this->findBucket($orig, 'forum_category');
2743
                $forumBucket = $this->findBucket($orig, 'forum');
2744
                $threadBucket = $this->findBucket($orig, 'forum_topic');
2745
                $postBucket = $this->findBucket($orig, 'forum_post');
2746
2747
                if (!empty($forumCat) && !empty($catsToKeep)) {
2748
                    $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] =
2749
                        array_intersect_key(
2750
                            $forumCat,
2751
                            array_fill_keys(array_map('strval', array_keys($catsToKeep)), true)
2752
                        );
2753
                }
2754
2755
                if (!empty($forumBucket)) {
2756
                    $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] =
2757
                        array_intersect_key($forumBucket, $selForums);
2758
                }
2759
                if (!empty($threadBucket)) {
2760
                    $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] =
2761
                        array_intersect_key(
2762
                            $threadBucket,
2763
                            array_fill_keys(array_map('strval', array_keys($threadToKeep)), true)
2764
                        );
2765
                }
2766
                if (!empty($postBucket)) {
2767
                    $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] =
2768
                        array_intersect_key(
2769
                            $postBucket,
2770
                            array_fill_keys(array_map('strval', array_keys($postToKeep)), true)
2771
                        );
2772
                }
2773
2774
                // If we have forums but no Forum_Category (edge), keep original categories
2775
                if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) {
2776
                    $out['Forum_Category'] = $forumCat;
2777
                }
2778
2779
                $out = array_filter($out);
2780
                $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out;
2781
2782
                $this->logDebug('[filterSelection] end (forums)', [
2783
                    'kept_types' => array_keys($course->resources),
2784
                    'forum_counts' => [
2785
                        'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0,
2786
                        'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0,
2787
                        'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0,
2788
                        'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0,
2789
                    ],
2790
                ]);
2791
2792
                return $course;
2793
            }
2794
        }
2795
2796
        // ---------- Generic + quiz/survey/gradebook ----------
2797
        $keep = [];
2798
        foreach ($selected as $type => $ids) {
2799
            if (!\is_array($ids) || empty($ids)) {
2800
                continue;
2801
            }
2802
            $legacyKey = $this->findBucketKey($orig, (string) $type);
2803
            if (null === $legacyKey) {
2804
                continue;
2805
            }
2806
            $bucket = $orig[$legacyKey] ?? [];
2807
            if (!empty($bucket) && \is_array($bucket)) {
2808
                $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true);
2809
                $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap);
2810
            }
2811
        }
2812
2813
        // Gradebook
2814
        $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']);
2815
        if ($gbKey && !empty($selected['gradebook'])) {
2816
            $gbBucket = $getBucket($orig, $gbKey);
2817
            if (!empty($gbBucket)) {
2818
                $selIds = array_keys(array_filter((array) $selected['gradebook']));
2819
                $firstItem = reset($gbBucket);
2820
2821
                if (\in_array('all', $selIds, true) || !\is_object($firstItem)) {
2822
                    $keep[$gbKey] = $gbBucket;
2823
                    $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]);
2824
                } else {
2825
                    $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true));
2826
                    $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]);
2827
                }
2828
            }
2829
        }
2830
2831
        // Quizzes -> questions (+ images)
2832
        $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']);
2833
        if ($quizKey && !empty($keep[$quizKey])) {
2834
            $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']);
2835
            if ($questionKey) {
2836
                $qids = [];
2837
                foreach ($keep[$quizKey] as $qid => $qwrap) {
2838
                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2839
                    if (!empty($q->question_ids) && \is_array($q->question_ids)) {
2840
                        foreach ($q->question_ids as $sid) {
2841
                            $qids[(string) $sid] = true;
2842
                        }
2843
                    }
2844
                }
2845
                if (!empty($qids)) {
2846
                    $questionBucket = $getBucket($orig, $questionKey);
2847
                    $selQ = array_intersect_key($questionBucket, $qids);
2848
                    if (!empty($selQ)) {
2849
                        $keep[$questionKey] = $selQ;
2850
2851
                        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2852
                        if ($docKey) {
2853
                            $docBucket = $getBucket($orig, $docKey);
2854
                            $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : [];
2855
                            if (!empty($imageQuizBucket)) {
2856
                                $needed = [];
2857
                                foreach ($keep[$questionKey] as $qid => $qwrap) {
2858
                                    $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap;
2859
                                    $pic = (string) ($q->picture ?? '');
2860
                                    if ('' !== $pic && isset($imageQuizBucket[$pic])) {
2861
                                        $needed[$pic] = true;
2862
                                    }
2863
                                }
2864
                                if (!empty($needed)) {
2865
                                    $keep[$docKey] = $keep[$docKey] ?? [];
2866
                                    $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed);
2867
                                }
2868
                            }
2869
                        }
2870
                    }
2871
                }
2872
            } else {
2873
                $this->logDebug('[filterSelection] quizzes selected but no question bucket found');
2874
            }
2875
        }
2876
2877
        // Surveys -> questions (+ invitations)
2878
        $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']);
2879
        if ($surveyKey && !empty($keep[$surveyKey])) {
2880
            $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']);
2881
            $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']);
2882
2883
            if ($surveyQuestionKey) {
2884
                $neededQids = [];
2885
                $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey]));
2886
2887
                foreach ($keep[$surveyKey] as $sid => $sWrap) {
2888
                    $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap;
2889
                    if (!empty($s->question_ids) && \is_array($s->question_ids)) {
2890
                        foreach ($s->question_ids as $qid) {
2891
                            $neededQids[(string) $qid] = true;
2892
                        }
2893
                    }
2894
                }
2895
                if (empty($neededQids)) {
2896
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2897
                    foreach ($surveyQBucket as $qid => $qWrap) {
2898
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2899
                        $qSurveyId = (string) ($q->survey_id ?? '');
2900
                        if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) {
2901
                            $neededQids[(string) $qid] = true;
2902
                        }
2903
                    }
2904
                }
2905
                if (!empty($neededQids)) {
2906
                    $surveyQBucket = $getBucket($orig, $surveyQuestionKey);
2907
                    $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids);
2908
                }
2909
            } else {
2910
                $this->logDebug('[filterSelection] surveys selected but no question bucket found');
2911
            }
2912
2913
            if ($surveyInvitationKey) {
2914
                $invBucket = $getBucket($orig, $surveyInvitationKey);
2915
                if (!empty($invBucket)) {
2916
                    $neededInv = [];
2917
                    foreach ($invBucket as $iid => $invWrap) {
2918
                        $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap;
2919
                        $sid = (string) ($inv->survey_id ?? '');
2920
                        if ('' !== $sid && isset($keep[$surveyKey][$sid])) {
2921
                            $neededInv[(string) $iid] = true;
2922
                        }
2923
                    }
2924
                    if (!empty($neededInv)) {
2925
                        $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv);
2926
                    }
2927
                }
2928
            }
2929
        }
2930
2931
        // Documents: add parent folders for selected files
2932
        $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']);
2933
        if ($docKey && !empty($keep[$docKey])) {
2934
            $docBucket = $getBucket($orig, $docKey);
2935
2936
            $foldersByRel = [];
2937
            foreach ($docBucket as $fid => $res) {
2938
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2939
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2940
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2941
                if (!$isFolder) {
2942
                    continue;
2943
                }
2944
2945
                $p = (string) ($e->path ?? '');
2946
                if ('' === $p) {
2947
                    continue;
2948
                }
2949
2950
                $frel = '/'.ltrim(substr($p, 8), '/');
2951
                $frel = rtrim($frel, '/').'/';
2952
                if ('//' !== $frel) {
2953
                    $foldersByRel[$frel] = $fid;
2954
                }
2955
            }
2956
2957
            $needFolderIds = [];
2958
            foreach ($keep[$docKey] as $id => $res) {
2959
                $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res;
2960
                $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
2961
                $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1));
2962
                if ($isFolder) {
2963
                    continue;
2964
                }
2965
2966
                $p = (string) ($e->path ?? '');
2967
                if ('' === $p) {
2968
                    continue;
2969
                }
2970
2971
                $rel = '/'.ltrim(substr($p, 8), '/');
2972
                $dir = rtrim(\dirname($rel), '/');
2973
                if ('' === $dir) {
2974
                    continue;
2975
                }
2976
2977
                $acc = '';
2978
                foreach (array_filter(explode('/', $dir)) as $seg) {
2979
                    $acc .= '/'.$seg;
2980
                    $accKey = rtrim($acc, '/').'/';
2981
                    if (isset($foldersByRel[$accKey])) {
2982
                        $needFolderIds[$foldersByRel[$accKey]] = true;
2983
                    }
2984
                }
2985
            }
2986
            if (!empty($needFolderIds)) {
2987
                $added = array_intersect_key($docBucket, $needFolderIds);
2988
                $keep[$docKey] += $added;
2989
            }
2990
        }
2991
2992
        // Links -> pull categories used by the selected links
2993
        $lnkKey = $this->firstExistingKey($orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']);
2994
        if ($lnkKey && !empty($keep[$lnkKey])) {
2995
            $catIdsUsed = [];
2996
            foreach ($keep[$lnkKey] as $lid => $lWrap) {
2997
                $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap;
2998
                $cid = (int) ($L->category_id ?? 0);
2999
                if ($cid > 0) {
3000
                    $catIdsUsed[(string) $cid] = true;
3001
                }
3002
            }
3003
3004
            $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']);
3005
            if ($catKey && !empty($catIdsUsed)) {
3006
                $catBucket = $getBucket($orig, $catKey);
3007
                if (!empty($catBucket)) {
3008
                    $subset = array_intersect_key($catBucket, $catIdsUsed);
3009
                    $keep[$catKey] = $subset;
3010
                    $keep['link_category'] = $subset; // mirror for convenience
3011
                }
3012
            }
3013
        }
3014
3015
        $keep = array_filter($keep);
3016
        $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep;
3017
3018
        $this->logDebug('[filterSelection] non-forum flow end', [
3019
            'selected_types' => array_keys($selected),
3020
            'orig_types' => array_keys($orig),
3021
            'kept_types' => array_keys($course->resources ?? []),
3022
        ]);
3023
3024
        return $course;
3025
    }
3026
3027
    /**
3028
     * Map UI options (1/2/3) to legacy file policy.
3029
     */
3030
    private function mapSameNameOption(int $opt): int
3031
    {
3032
        $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2;
3033
3034
        if (!\defined('FILE_SKIP')) {
3035
            \define('FILE_SKIP', 1);
3036
        }
3037
        if (!\defined('FILE_RENAME')) {
3038
            \define('FILE_RENAME', 2);
3039
        }
3040
        if (!\defined('FILE_OVERWRITE')) {
3041
            \define('FILE_OVERWRITE', 3);
3042
        }
3043
3044
        return match ($opt) {
3045
            1 => FILE_SKIP,
3046
            3 => FILE_OVERWRITE,
3047
            default => FILE_RENAME,
3048
        };
3049
    }
3050
3051
    /**
3052
     * Set debug mode from Request (query/header).
3053
     */
3054
    private function setDebugFromRequest(?Request $req): void
3055
    {
3056
        if (!$req) {
3057
            return;
3058
        }
3059
        // Query param wins
3060
        if ($req->query->has('debug')) {
3061
            $this->debug = $req->query->getBoolean('debug');
3062
3063
            return;
3064
        }
3065
        // Fallback to header
3066
        $hdr = $req->headers->get('X-Debug');
3067
        if (null !== $hdr) {
3068
            $val = trim((string) $hdr);
3069
            $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false'));
3070
        }
3071
    }
3072
3073
    /**
3074
     * Debug logger with stage + compact JSON payload.
3075
     */
3076
    private function logDebug(string $stage, mixed $payload = null): void
3077
    {
3078
        if (!$this->debug) {
3079
            return;
3080
        }
3081
        $prefix = 'COURSE_DEBUG';
3082
        if (null === $payload) {
3083
            error_log("$prefix: $stage");
3084
3085
            return;
3086
        }
3087
        // Safe/short json
3088
        $json = null;
3089
3090
        try {
3091
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
3092
            if (null !== $json && \strlen($json) > 8000) {
3093
                $json = substr($json, 0, 8000).'…(truncated)';
3094
            }
3095
        } catch (Throwable $e) {
3096
            $json = '[payload_json_error: '.$e->getMessage().']';
3097
        }
3098
        error_log("$prefix: $stage -> $json");
3099
    }
3100
3101
    /**
3102
     * Snapshot of resources bag for quick inspection.
3103
     */
3104
    private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array
3105
    {
3106
        $out = [];
3107
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3108
        $i = 0;
3109
        foreach ($res as $type => $bag) {
3110
            if ($i++ >= $maxTypes) {
3111
                $out['__notice'] = 'types truncated';
3112
3113
                break;
3114
            }
3115
            $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []];
3116
            if (\is_array($bag)) {
3117
                $j = 0;
3118
                foreach ($bag as $id => $obj) {
3119
                    if ($j++ >= $maxItemsPerType) {
3120
                        $snap['sample'][] = ['__notice' => 'truncated'];
3121
3122
                        break;
3123
                    }
3124
                    $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3125
                    $snap['sample'][] = [
3126
                        'id' => (string) $id,
3127
                        'cls' => \is_object($obj) ? $obj::class : \gettype($obj),
3128
                        'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [],
3129
                    ];
3130
                }
3131
            }
3132
            $out[(string) $type] = $snap;
3133
        }
3134
3135
        return $out;
3136
    }
3137
3138
    /**
3139
     * Snapshot of forum-family counters.
3140
     */
3141
    private function snapshotForumCounts(object $course): array
3142
    {
3143
        $r = \is_array($course->resources ?? null) ? $course->resources : [];
3144
        $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0;
3145
3146
        return [
3147
            'Forum_Category' => $get('Forum_Category', 'forum_category'),
3148
            'forum' => $get('forum', 'Forum'),
3149
            'thread' => $get('thread', 'forum_topic'),
3150
            'post' => $get('post', 'forum_post'),
3151
        ];
3152
    }
3153
3154
    /**
3155
     * Builds the selection map [type => [id => true]] from high-level types.
3156
     * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so
3157
     * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.).
3158
     *
3159
     * @param object   $course        Legacy Course with already hydrated resources
3160
     * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath'])
3161
     *
3162
     * @return array<string, array<int|string, bool>>
3163
     */
3164
    private function buildSelectionFromTypes(object $course, array $selectedTypes): array
3165
    {
3166
        $selectedTypes = array_map(
3167
            fn ($t) => $this->normalizeTypeKey((string) $t),
3168
            $selectedTypes
3169
        );
3170
3171
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3172
3173
        $coreDeps = [
3174
            'document', 'link', 'quiz', 'work', 'survey',
3175
            'Forum_Category', 'forum', 'thread', 'post',
3176
            'exercise_question', 'survey_question', 'link_category',
3177
        ];
3178
3179
        $presentKeys = array_fill_keys(array_map(
3180
            fn ($k) => $this->normalizeTypeKey((string) $k),
3181
            array_keys($res)
3182
        ), true);
3183
3184
        $out = [];
3185
3186
        $addBucket = function (string $typeKey) use (&$out, $res): void {
3187
            if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) {
3188
                return;
3189
            }
3190
            $ids = [];
3191
            foreach ($res[$typeKey] as $id => $_) {
3192
                $ids[(string) $id] = true;
3193
            }
3194
            if ($ids) {
3195
                $out[$typeKey] = $ids;
3196
            }
3197
        };
3198
3199
        foreach ($selectedTypes as $t) {
3200
            $addBucket($t);
3201
3202
            if ('learnpath' === $t) {
3203
                foreach ($coreDeps as $depRaw) {
3204
                    $dep = $this->normalizeTypeKey($depRaw);
3205
                    if (isset($presentKeys[$dep])) {
3206
                        $addBucket($dep);
3207
                    }
3208
                }
3209
            }
3210
        }
3211
3212
        $this->logDebug('[buildSelectionFromTypes] built', [
3213
            'selectedTypes' => $selectedTypes,
3214
            'kept_types' => array_keys($out),
3215
        ]);
3216
3217
        return $out;
3218
    }
3219
3220
    /**
3221
     * Build link tree (Category → Link) for the UI.
3222
     * Categories are not selectable; links are leaves (item_count = 0).
3223
     */
3224
    private function buildLinkTreeForVue(object $course, string $groupTitle): array
3225
    {
3226
        $this->logDebug('[buildLinkTreeForVue] start');
3227
3228
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3229
        $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? [];
3230
        $linkRaw = $res['link'] ?? $res['Link'] ?? [];
3231
3232
        $this->logDebug('[buildLinkTreeForVue] raw counts', [
3233
            'categories' => \is_array($catRaw) ? \count($catRaw) : 0,
3234
            'links' => \is_array($linkRaw) ? \count($linkRaw) : 0,
3235
        ]);
3236
3237
        $cats = [];
3238
        foreach ($catRaw as $id => $obj) {
3239
            $id = (int) $id;
3240
            if ($id <= 0 || !\is_object($obj)) {
3241
                continue;
3242
            }
3243
            $e = $this->objectEntity($obj);
3244
            $label = $this->resolveItemLabel('link_category', $e, $id);
3245
            $cats[$id] = [
3246
                'id' => $id,
3247
                'type' => 'link_category',
3248
                'label' => (('' !== $label ? $label : ('Category #'.$id)).'/'),
3249
                'selectable' => true,
3250
                'items' => [],
3251
                'has_children' => false,
3252
                'item_count' => 0,
3253
                'extra' => ['filetype' => 'folder'],
3254
            ];
3255
        }
3256
3257
        // Virtual "Uncategorized"
3258
        $uncatKey = -9999;
3259
        if (!isset($cats[$uncatKey])) {
3260
            $cats[$uncatKey] = [
3261
                'id' => $uncatKey,
3262
                'type' => 'link_category',
3263
                'label' => 'Uncategorized/',
3264
                'selectable' => true,
3265
                'items' => [],
3266
                '_virtual' => true,
3267
                'has_children' => false,
3268
                'item_count' => 0,
3269
                'extra' => ['filetype' => 'folder'],
3270
            ];
3271
        }
3272
3273
        // Assign links to categories
3274
        foreach ($linkRaw as $id => $obj) {
3275
            $id = (int) $id;
3276
            if ($id <= 0 || !\is_object($obj)) {
3277
                continue;
3278
            }
3279
            $e = $this->objectEntity($obj);
3280
3281
            $cid = (int) ($e->category_id ?? 0);
3282
            if (!isset($cats[$cid])) {
3283
                $cid = $uncatKey;
3284
            }
3285
3286
            $cats[$cid]['items'][] = [
3287
                'id' => $id,
3288
                'type' => 'link',
3289
                'label' => $this->resolveItemLabel('link', $e, $id),
3290
                'extra' => $this->buildExtra('link', $e) ?: new stdClass(),
3291
                'selectable' => true,
3292
                'item_count' => 0,
3293
            ];
3294
        }
3295
3296
        // Drop empty virtual category, sort, and finalize UI hints
3297
        $catNodes = array_values(array_filter($cats, static function ($c) {
3298
            if (!empty($c['_virtual']) && empty($c['items'])) {
3299
                return false;
3300
            }
3301
3302
            return true;
3303
        }));
3304
3305
        foreach ($catNodes as &$c) {
3306
            if (!empty($c['items'])) {
3307
                usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3308
            }
3309
            $c['has_children'] = !empty($c['items']);
3310
            $c['item_count'] = \count($c['items'] ?? []);
3311
        }
3312
        unset($c);
3313
3314
        usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']));
3315
3316
        $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]);
3317
3318
        return [
3319
            'type' => 'link',
3320
            'title' => $groupTitle,
3321
            'items' => $catNodes,
3322
        ];
3323
    }
3324
3325
    /**
3326
     * Leaves only the items selected by the UI in $course->resources.
3327
     * Expects $selected with the following form:
3328
     * [
3329
     * "documents" => ["123" => true, "124" => true],
3330
     * "links" => ["7" => true],
3331
     * "quiz" => ["45" => true],
3332
     * ...
3333
     * ].
3334
     */
3335
    private function filterCourseResources(object $course, array $selected): void
3336
    {
3337
        if (!isset($course->resources) || !\is_array($course->resources)) {
3338
            return;
3339
        }
3340
3341
        $typeMap = [
3342
            'documents' => RESOURCE_DOCUMENT,
3343
            'links' => RESOURCE_LINK,
3344
            'quizzes' => RESOURCE_QUIZ,
3345
            'quiz' => RESOURCE_QUIZ,
3346
            'quiz_questions' => RESOURCE_QUIZQUESTION,
3347
            'surveys' => RESOURCE_SURVEY,
3348
            'survey' => RESOURCE_SURVEY,
3349
            'survey_questions' => RESOURCE_SURVEYQUESTION,
3350
            'announcement' => RESOURCE_ANNOUNCEMENT,
3351
            'events' => RESOURCE_EVENT,
3352
            'course_description' => RESOURCE_COURSEDESCRIPTION,
3353
            'glossary' => RESOURCE_GLOSSARY,
3354
            'wiki' => RESOURCE_WIKI,
3355
            'thematic' => RESOURCE_THEMATIC,
3356
            'attendance' => RESOURCE_ATTENDANCE,
3357
            'works' => RESOURCE_WORK,
3358
            'gradebook' => RESOURCE_GRADEBOOK,
3359
            'learnpaths' => RESOURCE_LEARNPATH,
3360
            'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY,
3361
            'tool_intro' => RESOURCE_TOOL_INTRO,
3362
            'forums' => RESOURCE_FORUM,
3363
            'forum' => RESOURCE_FORUM,
3364
            'forum_topic' => RESOURCE_FORUMTOPIC,
3365
            'forum_post' => RESOURCE_FORUMPOST,
3366
        ];
3367
3368
        $allowed = [];
3369
        foreach ($selected as $k => $idsMap) {
3370
            $key = $typeMap[$k] ?? $k;
3371
            $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true);
3372
        }
3373
3374
        foreach ($course->resources as $rtype => $bucket) {
3375
            if (!isset($allowed[$rtype])) {
3376
                continue;
3377
            }
3378
            $keep = $allowed[$rtype];
3379
            $filtered = [];
3380
            foreach ((array) $bucket as $id => $obj) {
3381
                $iid = (int) ($obj->source_id ?? $id);
3382
                if (isset($keep[$iid])) {
3383
                    $filtered[$id] = $obj;
3384
                }
3385
            }
3386
            $course->resources[$rtype] = $filtered;
3387
        }
3388
    }
3389
3390
    /**
3391
     * Resolve absolute path of a backupId inside the backups directory, with safety checks.
3392
     */
3393
    private function resolveBackupPath(string $backupId): string
3394
    {
3395
        $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR);
3396
        $baseReal = realpath($base) ?: $base;
3397
3398
        $file = basename($backupId);
3399
        $path = $baseReal.DIRECTORY_SEPARATOR.$file;
3400
3401
        $real = realpath($path);
3402
3403
        if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) {
3404
            return $real;
3405
        }
3406
3407
        return $path;
3408
    }
3409
3410
    /**
3411
     * Load a legacy Course object from any backup:
3412
     * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic)
3413
     * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder
3414
     *
3415
     * IMPORTANT:
3416
     * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize).
3417
     * - Tries Moodle only when the package looks like Moodle.
3418
     * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic.
3419
     */
3420
    private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object
3421
    {
3422
        $path = $this->resolveBackupPath($backupId);
3423
3424
        $force = strtolower($force);
3425
        if ('dat' === $force || 'chamilo' === $force) {
3426
            $looksMoodle = false;
3427
            $preferChamilo = true;
3428
        } elseif ('moodle' === $force) {
3429
            $looksMoodle = true;
3430
            $preferChamilo = false;
3431
        } else {
3432
            $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path);
3433
            $preferChamilo = $this->zipHasCourseInfoDat($path);
3434
        }
3435
3436
        if ($preferChamilo || !$looksMoodle) {
3437
            CourseArchiver::setDebug($this->debug);
3438
3439
            try {
3440
                $course = CourseArchiver::readCourse($backupId, false);
3441
                if (\is_object($course)) {
3442
                    // … (resto igual)
3443
                    if (!isset($course->resources) || !\is_array($course->resources)) {
3444
                        $course->resources = [];
3445
                    }
3446
                    $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
3447
                    $course->resources['__meta']['import_source'] = 'chamilo';
3448
3449
                    return $course;
3450
                }
3451
            } catch (Throwable $e) {
3452
                $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]);
3453
            }
3454
3455
            $zipPath = $this->resolveBackupPath($backupId);
3456
            $ci = $this->readCourseInfoFromZip($zipPath);
3457
            if (empty($ci['ok'])) {
3458
                if ($looksMoodle) {
3459
                    $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort');
3460
3461
                    return $this->loadMoodleCourseOrFail($path);
3462
                }
3463
3464
                throw new RuntimeException('course_info.dat not found in backup');
3465
            }
3466
3467
            $raw = (string) $ci['data'];
3468
            $payload = base64_decode($raw, true);
3469
            if (false === $payload) {
3470
                $payload = $raw;
3471
            }
3472
3473
            $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload);
3474
            CourseArchiver::ensureLegacyAliases();
3475
3476
            set_error_handler(static function (): void {});
3477
3478
            try {
3479
                if (class_exists(UnserializeApi::class)) {
3480
                    $c = UnserializeApi::unserialize('course', $payload);
3481
                } else {
3482
                    $c = @unserialize($payload, ['allowed_classes' => true]);
3483
                }
3484
            } finally {
3485
                restore_error_handler();
3486
            }
3487
3488
            if (!\is_object($c ?? null)) {
3489
                if ($looksMoodle) {
3490
                    $this->logDebug('[loadLegacyCourseForAnyBackup] Chamilo fallback failed, trying MoodleImport');
3491
3492
                    return $this->loadMoodleCourseOrFail($path);
3493
                }
3494
3495
                throw new RuntimeException('Could not unserialize course (fallback)');
3496
            }
3497
3498
            if (!isset($c->resources) || !\is_array($c->resources)) {
3499
                $c->resources = [];
3500
            }
3501
            $c->resources['__meta'] = (array) ($c->resources['__meta'] ?? []);
3502
            $c->resources['__meta']['import_source'] = 'chamilo';
3503
3504
            return $c;
3505
        }
3506
3507
        // Moodle path
3508
        if ($looksMoodle) {
3509
            $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport');
3510
3511
            return $this->loadMoodleCourseOrFail($path);
3512
        }
3513
3514
        throw new RuntimeException('Unsupported package: neither course_info.dat nor moodle_backup.xml found.');
3515
    }
3516
3517
    /**
3518
     * Normalize resource buckets to the exact keys supported by CourseRestorer.
3519
     * Only the canonical keys below are produced; common aliases are mapped.
3520
     * - Never drop data: merge buckets; keep __meta as-is.
3521
     * - Make sure "document" survives if it existed before.
3522
     */
3523
    private function normalizeBucketsForRestorer(object $course): void
3524
    {
3525
        if (!isset($course->resources) || !\is_array($course->resources)) {
3526
            return;
3527
        }
3528
3529
        // Split meta buckets
3530
        $all = $course->resources;
3531
        $meta = [];
3532
        foreach ($all as $k => $v) {
3533
            if (\is_string($k) && str_starts_with($k, '__')) {
3534
                $meta[$k] = $v;
3535
                unset($all[$k]);
3536
            }
3537
        }
3538
3539
        // Start from current
3540
        $out = $all;
3541
3542
        // merge array buckets preserving numeric/string ids
3543
        $merge = static function (array $dst, array $src): array {
3544
            foreach ($src as $id => $obj) {
3545
                if (!\array_key_exists($id, $dst)) {
3546
                    $dst[$id] = $obj;
3547
                }
3548
            }
3549
3550
            return $dst;
3551
        };
3552
3553
        // safe alias map (input -> canonical). Extend only if needed.
3554
        $aliases = [
3555
            // documents
3556
            'documents' => 'document',
3557
            'Document' => 'document',
3558
            'document ' => 'document',
3559
3560
            // tool intro
3561
            'tool introduction' => 'tool_intro',
3562
            'tool_introduction' => 'tool_intro',
3563
            'tool/introduction' => 'tool_intro',
3564
            'tool intro' => 'tool_intro',
3565
            'Tool introduction' => 'tool_intro',
3566
3567
            // forums
3568
            'forums' => 'forum',
3569
            'Forum' => 'forum',
3570
            'Forum_Category' => 'forum_category',
3571
            'forumcategory' => 'forum_category',
3572
            'thread' => 'forum_topic',
3573
            'Thread' => 'forum_topic',
3574
            'forumtopic' => 'forum_topic',
3575
            'post' => 'forum_post',
3576
            'Post' => 'forum_post',
3577
            'forumpost' => 'forum_post',
3578
3579
            // links
3580
            'links' => 'link',
3581
            'link category' => 'link_category',
3582
3583
            // quiz + questions
3584
            'Exercise_Question' => 'exercise_question',
3585
            'exercisequestion' => 'exercise_question',
3586
3587
            // surveys
3588
            'surveys' => 'survey',
3589
            'surveyquestion' => 'survey_question',
3590
3591
            // announcements
3592
            'announcements' => 'announcement',
3593
            'Announcements' => 'announcement',
3594
        ];
3595
3596
        // Normalize keys (case/spacing) and apply alias merges
3597
        foreach ($all as $rawKey => $_bucket) {
3598
            if (!\is_array($_bucket)) {
3599
                continue; // defensive
3600
            }
3601
            $k = (string) $rawKey;
3602
            $norm = strtolower(trim(strtr($k, ['\\' => '/', '-' => '_'])));
3603
            $norm2 = str_replace('/', '_', $norm);
3604
3605
            $canonical = null;
3606
            if (isset($aliases[$norm])) {
3607
                $canonical = $aliases[$norm];
3608
            } elseif (isset($aliases[$norm2])) {
3609
                $canonical = $aliases[$norm2];
3610
            }
3611
3612
            if ($canonical && $canonical !== $rawKey) {
3613
                // Merge into canonical and drop the alias key
3614
                $out[$canonical] = isset($out[$canonical]) && \is_array($out[$canonical])
3615
                    ? $merge($out[$canonical], $_bucket)
3616
                    : $_bucket;
3617
                unset($out[$rawKey]);
3618
            }
3619
            // else: leave as-is (pass-through)
3620
        }
3621
3622
        // Safety: if there was any docs bucket under an alias, ensure 'document' is present.
3623
        if (!isset($out['document'])) {
3624
            if (isset($all['documents']) && \is_array($all['documents'])) {
3625
                $out['document'] = $all['documents'];
3626
            } elseif (isset($all['Document']) && \is_array($all['Document'])) {
3627
                $out['document'] = $all['Document'];
3628
            }
3629
        }
3630
3631
        // Gentle ordering for readability only (does not affect presence)
3632
        $order = [
3633
            'announcement', 'document', 'link', 'link_category',
3634
            'forum', 'forum_category', 'forum_topic', 'forum_post',
3635
            'quiz', 'exercise_question',
3636
            'survey', 'survey_question',
3637
            'learnpath', 'tool_intro',
3638
            'work',
3639
        ];
3640
        $w = [];
3641
        foreach ($order as $i => $key) {
3642
            $w[$key] = $i;
3643
        }
3644
        uksort($out, static function ($a, $b) use ($w) {
3645
            $wa = $w[$a] ?? 9999;
3646
            $wb = $w[$b] ?? 9999;
3647
3648
            return $wa <=> $wb ?: strcasecmp((string) $a, (string) $b);
3649
        });
3650
3651
        // Final assign: meta first, then normalized buckets
3652
        $course->resources = $meta + $out;
3653
3654
        // Debug trace to verify we didn't lose keys
3655
        $this->logDebug('[normalizeBucketsForRestorer] final keys', array_keys((array) $course->resources));
3656
    }
3657
3658
    /**
3659
     * Read import_source without depending on filtered resources.
3660
     * Falls back to $course->info['__import_source'] if needed.
3661
     */
3662
    private function getImportSource(object $course): string
3663
    {
3664
        $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? ''));
3665
        if ('' !== $src) {
3666
            return $src;
3667
        }
3668
3669
        // Fallbacks (defensive)
3670
        return strtolower((string) ($course->info['__import_source'] ?? ''));
3671
    }
3672
3673
    /**
3674
     * Builds a CC 1.3 preview from the legacy Course (only the implemented one).
3675
     * Returns a structure intended for rendering/committing before the actual export.
3676
     */
3677
    private function buildCc13Preview(object $course): array
3678
    {
3679
        $ims = [
3680
            'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document']
3681
            'resources' => [
3682
                'webcontent' => [],
3683
            ],
3684
            'counts' => ['files' => 0, 'folders' => 0],
3685
            'defaultSelection' => [
3686
                'documents' => [],
3687
            ],
3688
        ];
3689
3690
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3691
        $docKey = null;
3692
3693
        foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) {
3694
            if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) {
3695
                $docKey = $cand;
3696
3697
                break;
3698
            }
3699
        }
3700
        if (!$docKey) {
3701
            return $ims;
3702
        }
3703
3704
        foreach ($res[$docKey] as $iid => $wrap) {
3705
            if (!\is_object($wrap)) {
3706
                continue;
3707
            }
3708
            $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3709
3710
            $rawPath = (string) ($e->path ?? $e->full_path ?? '');
3711
            if ('' === $rawPath) {
3712
                continue;
3713
            }
3714
            $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/');
3715
3716
            $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? ''));
3717
            $isDir = ('folder' === $fileType) || ('/' === substr($rawPath, -1));
3718
3719
            $title = (string) ($e->title ?? $wrap->name ?? basename($rel));
3720
            $ims['resources']['webcontent'][] = [
3721
                'id' => (int) $iid,
3722
                'cc_type' => 'webcontent',
3723
                'title' => '' !== $title ? $title : basename($rel),
3724
                'rel' => $rel,
3725
                'is_dir' => $isDir,
3726
                'would_be_manifest_entry' => !$isDir,
3727
            ];
3728
3729
            if (!$isDir) {
3730
                $ims['defaultSelection']['documents'][(int) $iid] = true;
3731
                $ims['counts']['files']++;
3732
            } else {
3733
                $ims['counts']['folders']++;
3734
            }
3735
        }
3736
3737
        return $ims;
3738
    }
3739
3740
    /**
3741
     * Expand category selections (link/forum) to their item IDs using a full course snapshot.
3742
     * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel.
3743
     *
3744
     * @return array<string, array<string, bool>
3745
     */
3746
    private function expandCc13SelectionFromCategories(object $course, array $normSel): array
3747
    {
3748
        $out = [
3749
            'documents' => (array) ($normSel['documents'] ?? []),
3750
            'links' => (array) ($normSel['links'] ?? []),
3751
            'forums' => (array) ($normSel['forums'] ?? []),
3752
        ];
3753
3754
        $res = \is_array($course->resources ?? null) ? $course->resources : [];
3755
3756
        // Link categories → link IDs
3757
        if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) {
3758
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true);
3759
            $links = $res['link'] ?? $res['Link'];
3760
            foreach ($links as $lid => $wrap) {
3761
                if (!\is_object($wrap)) {
3762
                    continue;
3763
                }
3764
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3765
                $cid = (string) (int) ($e->category_id ?? 0);
3766
                if (isset($selCats[$cid])) {
3767
                    $out['links'][(string) $lid] = true;
3768
                }
3769
            }
3770
        }
3771
3772
        // Forum categories → forum IDs
3773
        if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) {
3774
            $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true);
3775
            $forums = $res['forum'] ?? $res['Forum'];
3776
            foreach ($forums as $fid => $wrap) {
3777
                if (!\is_object($wrap)) {
3778
                    continue;
3779
                }
3780
                $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap;
3781
                $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0);
3782
                if (isset($selCats[$cid])) {
3783
                    $out['forums'][(string) $fid] = true;
3784
                }
3785
            }
3786
        }
3787
3788
        return $out;
3789
    }
3790
3791
    /**
3792
     * Infer tool buckets required by a given selection payload (used in 'selected' scope).
3793
     *
3794
     * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": <int> }
3795
     *
3796
     * @param array<int,array<string,mixed>> $selected
3797
     *
3798
     * @return string[]
3799
     */
3800
    private function inferToolsFromSelection(array $selected): array
3801
    {
3802
        $has = static fn (string $k): bool => !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0;
3803
3804
        $want = [];
3805
3806
        // documents
3807
        if ($has('document')) {
3808
            $want[] = 'documents';
3809
        }
3810
3811
        // links (categories imply links too)
3812
        if ($has('link') || $has('link_category')) {
3813
            $want[] = 'links';
3814
        }
3815
3816
        // forums (any of the family implies forums)
3817
        if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) {
3818
            $want[] = 'forums';
3819
        }
3820
3821
        // quizzes / questions
3822
        if ($has('quiz') || $has('exercise') || $has('exercise_question')) {
3823
            $want[] = 'quizzes';
3824
            $want[] = 'quiz_questions';
3825
        }
3826
3827
        // surveys / questions / invitations
3828
        if ($has('survey') || $has('survey_question') || $has('survey_invitation')) {
3829
            $want[] = 'surveys';
3830
            $want[] = 'survey_questions';
3831
        }
3832
3833
        // learnpaths
3834
        if ($has('learnpath') || $has('learnpath_category')) {
3835
            $want[] = 'learnpaths';
3836
            $want[] = 'learnpath_category';
3837
        }
3838
3839
        // others
3840
        if ($has('work'))     { $want[] = 'works'; }
3841
        if ($has('glossary')) { $want[] = 'glossary'; }
3842
        if ($has('tool_intro')) { $want[] = 'tool_intro'; }
3843
        if ($has('attendance')) { $want[] = 'attendance'; }
3844
        if ($has('announcement')) { $want[] = 'announcement'; }
3845
        if ($has('calendar_event')) { $want[] = 'events'; }
3846
        if ($has('wiki')) { $want[] = 'wiki'; }
3847
        if ($has('thematic')) { $want[] = 'thematic'; }
3848
        if ($has('gradebook')) { $want[] = 'gradebook'; }
3849
3850
        if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; }
3851
        if ($has('work')) {
3852
            $want[] = 'works';
3853
        }
3854
        if ($has('glossary')) {
3855
            $want[] = 'glossary';
3856
        }
3857
        if ($has('tool_intro')) {
3858
            $want[] = 'tool_intro';
3859
        }
3860
        if ($has('course_descriptions') || $has('course_description')) {
3861
            $tools[] = 'course_descriptions';
3862
        }
3863
3864
        // Dedup
3865
        return array_values(array_unique(array_filter($want)));
3866
    }
3867
3868
    private function intersectBucketByIds(array $bucket, array $idsMap): array
3869
    {
3870
        $out = [];
3871
        foreach ($bucket as $id => $obj) {
3872
            $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj;
3873
            $k1 = (string) $id;
3874
            $k2 = (string) ($ent->source_id ?? $obj->source_id ?? '');
3875
            if (isset($idsMap[$k1]) || ('' !== $k2 && isset($idsMap[$k2]))) {
3876
                $out[$id] = $obj;
3877
            }
3878
        }
3879
3880
        return $out;
3881
    }
3882
3883
    private function bucketKeyCandidates(string $type): array
3884
    {
3885
        $t = $this->normalizeTypeKey($type);
3886
3887
        // Constants (string values) if defined
3888
        $RD = \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : '';
3889
        $RL = \defined('RESOURCE_LINK') ? (string) RESOURCE_LINK : '';
3890
        $RF = \defined('RESOURCE_FORUM') ? (string) RESOURCE_FORUM : '';
3891
        $RFT = \defined('RESOURCE_FORUMTOPIC') ? (string) RESOURCE_FORUMTOPIC : '';
3892
        $RFP = \defined('RESOURCE_FORUMPOST') ? (string) RESOURCE_FORUMPOST : '';
3893
        $RQ = \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : '';
3894
        $RQQ = \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : '';
3895
        $RS = \defined('RESOURCE_SURVEY') ? (string) RESOURCE_SURVEY : '';
3896
        $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : '';
3897
3898
        $map = [
3899
            'document' => ['document', 'Document', $RD],
3900
            'link' => ['link', 'Link', $RL],
3901
            'link_category' => ['link_category', 'Link_Category'],
3902
            'forum' => ['forum', 'Forum', $RF],
3903
            'forum_category' => ['forum_category', 'Forum_Category'],
3904
            'forum_topic' => ['forum_topic', 'thread', $RFT],
3905
            'forum_post' => ['forum_post', 'post', $RFP],
3906
            'quiz' => ['quiz', 'Quiz', $RQ],
3907
            'exercise_question' => ['Exercise_Question', 'exercise_question', $RQQ],
3908
            'survey' => ['survey', 'Survey', $RS],
3909
            'survey_question' => ['Survey_Question', 'survey_question', $RSQ],
3910
            'tool_intro' => ['tool_intro', 'Tool introduction'],
3911
        ];
3912
3913
        $c = $map[$t] ?? [$t, ucfirst($t)];
3914
3915
        return array_values(array_filter($c, static fn ($x) => '' !== $x));
3916
    }
3917
3918
    private function findBucketKey(array $res, string $type): ?string
3919
    {
3920
        $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type));
3921
3922
        return null !== $key ? (string) $key : null;
3923
    }
3924
3925
    private function findBucket(array $res, string $type): array
3926
    {
3927
        $k = $this->findBucketKey($res, $type);
3928
3929
        return (null !== $k && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : [];
3930
    }
3931
3932
    /**
3933
     * True if file extension suggests a Moodle backup.
3934
     */
3935
    private function isMoodleByExt(string $path): bool
3936
    {
3937
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3938
3939
        return \in_array($ext, ['mbz', 'tgz', 'gz'], true);
3940
    }
3941
3942
    /**
3943
     * Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files.
3944
     */
3945
    private function zipHasMoodleBackupXml(string $path): bool
3946
    {
3947
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3948
        // Many .mbz are plain ZIPs; try to open if extension is zip/mbz
3949
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3950
            return false;
3951
        }
3952
        $zip = new ZipArchive();
3953
        if (true !== ($err = $zip->open($path))) {
3954
            return false;
3955
        }
3956
        $idx = $zip->locateName('moodle_backup.xml', ZipArchive::FL_NOCASE);
3957
        $zip->close();
3958
3959
        return false !== $idx;
3960
    }
3961
3962
    /**
3963
     * Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files.
3964
     */
3965
    private function zipHasCourseInfoDat(string $path): bool
3966
    {
3967
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3968
        if (!\in_array($ext, ['zip', 'mbz'], true)) {
3969
            return false;
3970
        }
3971
        $zip = new ZipArchive();
3972
        if (true !== ($err = $zip->open($path))) {
3973
            return false;
3974
        }
3975
        // common locations
3976
        foreach (['course_info.dat', 'course/course_info.dat', 'backup/course_info.dat'] as $cand) {
3977
            $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE);
3978
            if (false !== $idx) {
3979
                $zip->close();
3980
3981
                return true;
3982
            }
3983
        }
3984
        $zip->close();
3985
3986
        return false;
3987
    }
3988
3989
    /**
3990
     * Build legacy Course graph from a Moodle archive and set __meta.import_source.
3991
     * Throws RuntimeException on failure.
3992
     */
3993
    private function loadMoodleCourseOrFail(string $absPath): object
3994
    {
3995
        if (!class_exists(MoodleImport::class)) {
3996
            throw new RuntimeException('MoodleImport class not available');
3997
        }
3998
        $importer = new MoodleImport(debug: $this->debug);
3999
4000
        if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) {
4001
            throw new RuntimeException('MoodleImport::buildLegacyCourseFromMoodleArchive() not available');
4002
        }
4003
4004
        $course = $importer->buildLegacyCourseFromMoodleArchive($absPath);
4005
4006
        if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) {
4007
            throw new RuntimeException('Moodle backup contains no importable resources');
4008
        }
4009
4010
        $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []);
4011
        $course->resources['__meta']['import_source'] = 'moodle';
4012
4013
        return $course;
4014
    }
4015
4016
    /**
4017
     * Clone a course snapshot keeping only the allowed buckets (preserving original-case keys).
4018
     * This is a shallow clone of the course object with a filtered 'resources' array.
4019
     */
4020
    private function cloneCourseWithBuckets(object $course, array $allowed): object
4021
    {
4022
        $clone = clone $course;
4023
4024
        if (!isset($course->resources) || !\is_array($course->resources)) {
4025
            $clone->resources = [];
4026
            return $clone;
4027
        }
4028
4029
        // Build a lookup based on the original-case keys present in $allowed
4030
        $allowedLookup = array_flip($allowed);
4031
4032
        // Intersect by original-case keys
4033
        $clone->resources = array_intersect_key($course->resources, $allowedLookup);
4034
4035
        return $clone;
4036
    }
4037
4038
    /**
4039
     * Generic runner for a bucket group: checks if it was requested, extracts present snapshot keys,
4040
     * appends legacy constant keys (if defined), and delegates to MoodleImport::restoreSelectedBuckets().
4041
     *
4042
     * Returns:
4043
     *   - array stats when executed,
4044
     *   - ['imported'=>0,'notes'=>['No ... buckets']] when requested but none present,
4045
     *   - null when not requested at all.
4046
     */
4047
    private function runBucketRestore(
4048
        MoodleImport           $importer,
4049
        array                  $requestedNormalized,
4050
        array                  $requestAliases,
4051
        array                  $snapshotAliases,
4052
        array                  $legacyConstNames,
4053
        object                 $course,
4054
        string                 $backupPath,
4055
        EntityManagerInterface $em,
4056
        int                    $cid,
4057
        int                    $sid,
4058
        int                    $sameFileNameOption,
4059
        string $statKey
4060
    ): ?array {
4061
        // Normalize request aliases and check if intersect
4062
        $norm = static fn(string $k): string => strtolower((string) $k);
4063
        $reqSet = array_map($norm, $requestAliases);
4064
4065
        if (count(array_intersect($requestedNormalized, $reqSet)) === 0) {
4066
            // Not requested -> skip quietly
4067
            return null;
4068
        }
4069
4070
        // Gather snapshot-present keys
4071
        $resources = (array) ($course->resources ?? []);
4072
        $present = array_keys($resources);
4073
        $presentNorm = array_map($norm, $present);
4074
4075
        $snapWanted = array_map($norm, $snapshotAliases);
4076
        $allowed = [];
4077
        foreach ($present as $idx => $origKey) {
4078
            if (in_array($presentNorm[$idx], $snapWanted, true)) {
4079
                $allowed[] = $origKey; // keep original key casing as in snapshot
4080
            }
4081
4082
            return $out;
4083
        }
4084
4085
        // Add legacy constant-based keys if defined
4086
        foreach ($legacyConstNames as $c) {
4087
            if (\defined($c)) {
4088
                $allowed[] = (string) \constant($c);
4089
            }
4090
4091
            return (object) $clean;
4092
        }
4093
4094
        // Deduplicate + sanitize
4095
        $allowed = array_values(array_unique(array_filter($allowed, static fn($v) => \is_string($v) && $v !== '')));
4096
4097
        // Quick clone of the course with only these buckets
4098
        $courseForThis = $this->cloneCourseWithBuckets($course, $allowed);
4099
4100
        if (empty((array) ($courseForThis->resources ?? []))) {
4101
            $this->logDebug("[runBucketRestore] {$statKey} skipped (no matching buckets present)", [
4102
                'requested' => $requestAliases,
4103
                'snapshot_aliases' => $snapshotAliases,
4104
                'legacy' => $legacyConstNames,
4105
                'available' => array_keys((array) $course->resources),
4106
            ]);
4107
            return ['imported' => 0, 'notes' => ["No {$statKey} buckets"]];
4108
        }
4109
4110
        if (\method_exists($importer, 'attachContext')) {
4111
            // Optional internal context
4112
            $importer->attachContext($backupPath, $em, $cid, $sid, $sameFileNameOption);
4113
        }
4114
4115
        // Delegate with stable signature
4116
        return $importer->restoreSelectedBuckets(
4117
            $backupPath,
4118
            $em,
4119
            $cid,
4120
            $sid,
4121
            $allowed,
4122
            $courseForThis
4123
        );
4124
    }
4125
4126
    private static function getPhpUploadLimitBytes(): int
4127
    {
4128
        // Use the strictest PHP limit (min of upload_max_filesize and post_max_size).
4129
        $limits = [];
4130
4131
        $u = \ini_get('upload_max_filesize');
4132
        if (\is_string($u)) {
4133
            $limits[] = self::iniSizeToBytes($u);
4134
        }
4135
4136
        $p = \ini_get('post_max_size');
4137
        if (\is_string($p)) {
4138
            $limits[] = self::iniSizeToBytes($p);
4139
        }
4140
4141
        // Keep only positive limits. If both are 0, treat as "no limit".
4142
        $limits = array_values(array_filter($limits, static fn (int $v): bool => $v > 0));
4143
4144
        return empty($limits) ? 0 : min($limits);
4145
    }
4146
4147
    private static function iniSizeToBytes(string $val): int
4148
    {
4149
        // Parses values like "2G", "512M", "900K", "1048576".
4150
        $val = trim($val);
4151
        if ('' === $val) {
4152
            return 0;
4153
        }
4154
        if ('0' === $val) {
4155
            return 0; // "no limit" for upload/post.
4156
        }
4157
4158
        if (!preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*([kmgt])?b?$/i', $val, $m)) {
4159
            return (int) $val;
4160
        }
4161
4162
        $num = (float) $m[1];
4163
        $unit = strtolower((string) ($m[2] ?? ''));
4164
4165
        switch ($unit) {
4166
            case 't':
4167
                $num *= 1024;
4168
4169
                // no break
4170
            case 'g':
4171
                $num *= 1024;
4172
4173
                // no break
4174
            case 'm':
4175
                $num *= 1024;
4176
4177
                // no break
4178
            case 'k':
4179
                $num *= 1024;
4180
4181
                break;
4182
        }
4183
4184
        return (int) round($num);
4185
    }
4186
}
4187