CourseRestorer::dlog()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 11
nc 4
nop 2
dl 0
loc 17
rs 9.9
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
/* For licensing terms, see /license.txt */
8
9
namespace Chamilo\CourseBundle\Component\CourseCopy;
10
11
use AllowDynamicProperties;
12
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
13
use Chamilo\CoreBundle\Entity\GradebookCategory;
14
use Chamilo\CoreBundle\Entity\GradebookEvaluation;
15
use Chamilo\CoreBundle\Entity\GradebookLink;
16
use Chamilo\CoreBundle\Entity\GradeModel;
17
use Chamilo\CoreBundle\Entity\ResourceLink;
18
use Chamilo\CoreBundle\Entity\ResourceNode;
19
use Chamilo\CoreBundle\Entity\Room;
20
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
21
use Chamilo\CoreBundle\Entity\Tool;
22
use Chamilo\CoreBundle\Framework\Container;
23
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
24
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
25
use Chamilo\CoreBundle\Tool\User;
26
use Chamilo\CourseBundle\Component\CourseCopy\Resources\LearnPathCategory;
27
use Chamilo\CourseBundle\Entity\CAnnouncement;
28
use Chamilo\CourseBundle\Entity\CAnnouncementAttachment;
29
use Chamilo\CourseBundle\Entity\CAttendance;
30
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
31
use Chamilo\CourseBundle\Entity\CAttendanceCalendarRelGroup;
32
use Chamilo\CourseBundle\Entity\CCalendarEvent;
33
use Chamilo\CourseBundle\Entity\CCalendarEventAttachment;
34
use Chamilo\CourseBundle\Entity\CCourseDescription;
35
use Chamilo\CourseBundle\Entity\CDocument;
36
use Chamilo\CourseBundle\Entity\CForum;
37
use Chamilo\CourseBundle\Entity\CForumCategory;
38
use Chamilo\CourseBundle\Entity\CForumPost;
39
use Chamilo\CourseBundle\Entity\CForumThread;
40
use Chamilo\CourseBundle\Entity\CGlossary;
41
use Chamilo\CourseBundle\Entity\CLink;
42
use Chamilo\CourseBundle\Entity\CLinkCategory;
43
use Chamilo\CourseBundle\Entity\CLp;
44
use Chamilo\CourseBundle\Entity\CLpCategory;
45
use Chamilo\CourseBundle\Entity\CLpItem;
46
use Chamilo\CourseBundle\Entity\CQuiz;
47
use Chamilo\CourseBundle\Entity\CQuizAnswer;
48
use Chamilo\CourseBundle\Entity\CQuizQuestion;
49
use Chamilo\CourseBundle\Entity\CQuizQuestionOption;
50
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
51
use Chamilo\CourseBundle\Entity\CStudentPublication;
52
use Chamilo\CourseBundle\Entity\CStudentPublicationAssignment;
53
use Chamilo\CourseBundle\Entity\CSurvey;
54
use Chamilo\CourseBundle\Entity\CSurveyQuestion;
55
use Chamilo\CourseBundle\Entity\CSurveyQuestionOption;
56
use Chamilo\CourseBundle\Entity\CThematic;
57
use Chamilo\CourseBundle\Entity\CThematicAdvance;
58
use Chamilo\CourseBundle\Entity\CThematicPlan;
59
use Chamilo\CourseBundle\Entity\CTool;
60
use Chamilo\CourseBundle\Entity\CToolIntro;
61
use Chamilo\CourseBundle\Entity\CWiki;
62
use Chamilo\CourseBundle\Entity\CWikiConf;
63
use Chamilo\CourseBundle\Repository\CAnnouncementAttachmentRepository;
64
use Chamilo\CourseBundle\Repository\CGlossaryRepository;
65
use Chamilo\CourseBundle\Repository\CStudentPublicationRepository;
66
use Chamilo\CourseBundle\Repository\CWikiRepository;
67
use CourseManager;
68
use Database;
69
use DateTime;
70
use DateTimeInterface;
71
use DateTimeZone;
72
use Doctrine\Common\Collections\ArrayCollection;
73
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
74
use Doctrine\ORM\EntityManagerInterface;
75
use DocumentManager;
76
use FilesystemIterator;
77
use learnpath;
78
use PhpZip\ZipFile;
79
use RecursiveDirectoryIterator;
80
use RecursiveIteratorIterator;
81
use stdClass;
82
use SurveyManager;
83
use Symfony\Component\HttpFoundation\File\UploadedFile;
84
use Symfony\Component\HttpKernel\KernelInterface;
85
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
86
use Symfony\Component\Routing\RouterInterface;
87
use Throwable;
88
89
use const ENT_QUOTES;
90
use const FILEINFO_MIME_TYPE;
91
use const JSON_PARTIAL_OUTPUT_ON_ERROR;
92
use const JSON_UNESCAPED_SLASHES;
93
use const JSON_UNESCAPED_UNICODE;
94
use const PATHINFO_EXTENSION;
95
96
/**
97
 * Class CourseRestorer.
98
 *
99
 * Class to restore items from a course object to a Chamilo-course
100
 *
101
 * @author Bart Mollet <[email protected]>
102
 * @author Julio Montoya <[email protected]> Several fixes/improvements
103
 */
104
#[AllowDynamicProperties]
105
class CourseRestorer
106
{
107
    /**
108
     * Debug flag (default: true). Toggle with setDebug().
109
     */
110
    private bool $debug = true;
111
112
    /**
113
     * The course-object.
114
     */
115
    public $course;
116
    public $destination_course_info;
117
118
    /**
119
     * What to do with files with same name (FILE_SKIP, FILE_RENAME, FILE_OVERWRITE).
120
     */
121
    public $file_option;
122
    public $set_tools_invisible_by_default;
123
    public $skip_content;
124
125
    /**
126
     * Restore order (keep existing order; docs first).
127
     */
128
    public $tools_to_restore = [
129
        'documents',
130
        'announcements',
131
        'attendance',
132
        'course_descriptions',
133
        'events',
134
        'forum_category',
135
        'forums',
136
        'glossary',
137
        'quizzes',
138
        'test_category',
139
        'links',
140
        'works',
141
        'surveys',
142
        'learnpath_category',
143
        'learnpaths',
144
        'scorm_documents',
145
        'tool_intro',
146
        'thematic',
147
        'wiki',
148
        'gradebook',
149
        'assets',
150
    ];
151
152
    /**
153
     * Setting per tool.
154
     */
155
    public $tool_copy_settings = [];
156
157
    /**
158
     * If true adds the text "copy" in the title of an item (only for LPs right now).
159
     */
160
    public $add_text_in_items = false;
161
162
    public $destination_course_id;
163
    public bool $copySessionContent = false;
164
165
    /**
166
     * Optional course origin id (legacy).
167
     */
168
    private $course_origin_id;
169
170
    /**
171
     * First teacher (owner) used for forums/posts.
172
     */
173
    private $first_teacher_id = 0;
174
175
    private array $htmlFoldersByCourseDir = [];
176
177
    /**
178
     * @var array<string,array>
179
     */
180
    private array $resources_all_snapshot = [];
181
182
    /**
183
     * @param Course $course
184
     */
185
    public function __construct($course)
186
    {
187
        // Read env constant/course hint if present
188
        if (\defined('COURSE_RESTORER_DEBUG')) {
189
            $this->debug = (bool) \constant('COURSE_RESTORER_DEBUG');
190
        }
191
192
        $this->course = $course;
193
        $courseInfo = api_get_course_info($this->course->code);
194
        $this->course_origin_id = !empty($courseInfo) ? $courseInfo['real_id'] : null;
195
196
        $this->file_option = FILE_RENAME;
197
        $this->set_tools_invisible_by_default = false;
198
        $this->skip_content = [];
199
200
        $this->dlog('Ctor: initial course info', [
201
            'course_code' => $this->course->code ?? null,
202
            'origin_id' => $this->course_origin_id,
203
            'has_resources' => \is_array($this->course->resources ?? null),
204
            'resource_keys' => array_keys((array) ($this->course->resources ?? [])),
205
        ]);
206
    }
207
208
    /**
209
     * Set the file-option.
210
     *
211
     * @param int $option FILE_SKIP, FILE_RENAME or FILE_OVERWRITE
212
     */
213
    public function set_file_option($option = FILE_OVERWRITE): void
214
    {
215
        $this->file_option = $option;
216
        $this->dlog('File option set', ['file_option' => $this->file_option]);
217
    }
218
219
    /**
220
     * @param bool $status
221
     */
222
    public function set_add_text_in_items($status): void
223
    {
224
        $this->add_text_in_items = $status;
225
    }
226
227
    /**
228
     * @param array $array
229
     */
230
    public function set_tool_copy_settings($array): void
231
    {
232
        $this->tool_copy_settings = $array;
233
    }
234
235
    /**
236
     * Entry point.
237
     *
238
     * @param mixed $destination_course_code
239
     * @param mixed $session_id
240
     * @param mixed $update_course_settings
241
     * @param mixed $respect_base_content
242
     */
243
    public function restore(
244
        $destination_course_code = '',
245
        $session_id = 0,
246
        $update_course_settings = false,
247
        $respect_base_content = false
248
    ) {
249
        $this->dlog('Restore() called', [
250
            'destination_code' => $destination_course_code,
251
            'session_id' => (int) $session_id,
252
            'update_course_settings' => (bool) $update_course_settings,
253
            'respect_base_content' => (bool) $respect_base_content,
254
        ]);
255
256
        // Resolve destination course
257
        $course_info = '' === $destination_course_code
258
            ? api_get_course_info()
259
            : api_get_course_info($destination_course_code);
260
261
        if (empty($course_info) || empty($course_info['real_id'])) {
262
            $this->dlog('Destination course not resolved or missing real_id', ['course_info' => $course_info]);
263
264
            return false;
265
        }
266
267
        $this->destination_course_info = $course_info;
268
        $this->destination_course_id = (int) $course_info['real_id'];
269
        $this->destination_course_entity = api_get_course_entity($this->destination_course_id);
270
271
        // Resolve teacher for forum/thread/post ownership
272
        $this->first_teacher_id = api_get_user_id();
273
        $teacher_list = CourseManager::get_teacher_list_from_course_code($course_info['code']);
274
        if (!empty($teacher_list)) {
275
            foreach ($teacher_list as $t) {
276
                $this->first_teacher_id = (int) $t['user_id'];
277
278
                break;
279
            }
280
        }
281
282
        if (empty($this->course)) {
283
            $this->dlog('No source course found');
284
285
            return false;
286
        }
287
288
        // Encoding detection/normalization
289
        if (empty($this->course->encoding)) {
290
            $sample_text = $this->course->get_sample_text()."\n";
291
            $lines = explode("\n", $sample_text);
292
            foreach ($lines as $k => $line) {
293
                if (api_is_valid_ascii($line)) {
294
                    unset($lines[$k]);
295
                }
296
            }
297
            $sample_text = implode("\n", $lines);
298
            $this->course->encoding = api_detect_encoding($sample_text, $course_info['language']);
299
        }
300
        $this->course->to_system_encoding();
301
        $this->dlog('Encoding resolved', ['encoding' => $this->course->encoding ?? '']);
302
303
        // Normalize forum bags
304
        $this->normalizeForumKeys();
305
        $this->ensureDepsBagsFromSnapshot();
306
        // Dump a compact view of the resource bags before restoring
307
        $this->debug_course_resources_simple(null);
308
309
        // Restore tools
310
        foreach ($this->tools_to_restore as $tool) {
311
            $fn = 'restore_'.$tool;
312
            if (method_exists($this, $fn)) {
313
                $this->dlog('Starting tool restore', ['tool' => $tool]);
314
315
                try {
316
                    $this->{$fn}($session_id, $respect_base_content, $destination_course_code);
317
                } catch (Throwable $e) {
318
                    $this->dlog('Tool restore failed with exception', [
319
                        'tool' => $tool,
320
                        'error' => $e->getMessage(),
321
                    ]);
322
                    $this->resetDoctrineIfClosed();
323
                }
324
                $this->dlog('Finished tool restore', ['tool' => $tool]);
325
            } else {
326
                $this->dlog('Restore method not found for tool (skipping)', ['tool' => $tool]);
327
            }
328
        }
329
330
        // Optionally restore safe course settings
331
        if ($update_course_settings) {
332
            $this->dlog('Restoring course settings');
333
            $this->restore_course_settings($destination_course_code);
334
        }
335
336
        $this->dlog('Restore() finished', [
337
            'destination_course_id' => $this->destination_course_id,
338
        ]);
339
340
        return null;
341
    }
342
343
    /**
344
     * Restore only harmless course settings (Chamilo 2 entity-safe).
345
     */
346
    public function restore_course_settings(string $destination_course_code = ''): void
347
    {
348
        $this->dlog('restore_course_settings() called');
349
350
        $courseEntity = null;
351
352
        if ('' !== $destination_course_code) {
353
            $courseEntity = Container::getCourseRepository()->findOneByCode($destination_course_code);
354
        } else {
355
            if (!empty($this->destination_course_id)) {
356
                $courseEntity = api_get_course_entity((int) $this->destination_course_id);
357
            } else {
358
                $info = api_get_course_info();
359
                if (!empty($info['real_id'])) {
360
                    $courseEntity = api_get_course_entity((int) $info['real_id']);
361
                }
362
            }
363
        }
364
365
        if (!$courseEntity) {
366
            $this->dlog('No destination course entity found, skipping settings restore');
367
368
            return;
369
        }
370
371
        $src = $this->course->info ?? [];
372
373
        if (!empty($src['language'])) {
374
            $courseEntity->setCourseLanguage((string) $src['language']);
375
        }
376
        if (isset($src['visibility']) && '' !== $src['visibility']) {
377
            $courseEntity->setVisibility((int) $src['visibility']);
378
        }
379
        if (\array_key_exists('department_name', $src)) {
380
            $courseEntity->setDepartmentName((string) $src['department_name']);
381
        }
382
        if (\array_key_exists('department_url', $src)) {
383
            $courseEntity->setDepartmentUrl((string) $src['department_url']);
384
        }
385
        if (!empty($src['category_id'])) {
386
            $catRepo = Container::getCourseCategoryRepository();
387
            $cat = $catRepo?->find((int) $src['category_id']);
388
            if ($cat) {
389
                $courseEntity->setCategories(new ArrayCollection([$cat]));
390
            }
391
        }
392
        if (\array_key_exists('subscribe_allowed', $src)) {
393
            $courseEntity->setSubscribe((bool) $src['subscribe_allowed']);
394
        }
395
        if (\array_key_exists('unsubscribe', $src)) {
396
            $courseEntity->setUnsubscribe((bool) $src['unsubscribe']);
397
        }
398
399
        $em = Database::getManager();
400
        $em->persist($courseEntity);
401
        $em->flush();
402
403
        $this->dlog('Course settings restored');
404
    }
405
406
    /**
407
     * Restore documents.
408
     *
409
     * @param mixed $session_id
410
     * @param mixed $respect_base_content
411
     * @param mixed $destination_course_code
412
     */
413
    public function restore_documents($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void
414
    {
415
        if (!$this->course->has_resources(RESOURCE_DOCUMENT)) {
416
            $this->dlog('restore_documents: no document resources');
417
418
            return;
419
        }
420
421
        $courseInfo   = $this->destination_course_info;
422
        $docRepo      = Container::getDocumentRepository();
423
        $courseEntity = api_get_course_entity($this->destination_course_id);
424
        $session      = api_get_session_entity((int) $session_id);
425
        $group        = api_get_group_entity(0);
426
427
        // Resolve the import root deterministically:
428
        $resolveImportRoot = function (): string {
429
            $metaRoot = (string) ($this->course->resources['__meta']['archiver_root'] ?? '');
430
            if ($metaRoot !== '' && is_dir($metaRoot) && (is_file($metaRoot.'/course_info.dat') || is_dir($metaRoot.'/document'))) {
431
                $this->dlog('resolveImportRoot: using meta.archiver_root', ['dir' => $metaRoot]);
432
433
                return rtrim($metaRoot, '/');
434
            }
435
436
            $bp = (string) ($this->course->backup_path ?? '');
437
            if ($bp !== '') {
438
                if (is_dir($bp) && (is_file($bp.'/course_info.dat') || is_dir($bp.'/document'))) {
439
                    $this->dlog('resolveImportRoot: using backup_path (dir)', ['dir' => $bp]);
440
441
                    return rtrim($bp, '/');
442
                }
443
444
                if (is_file($bp) && preg_match('/\.zip$/i', $bp)) {
445
                    $base = dirname($bp);
446
                    $cands = glob($base.'/CourseArchiver_*', GLOB_ONLYDIR) ?: [];
447
                    if (empty($cands) && is_dir($base)) {
448
                        $tmp = array_diff(scandir($base) ?: [], ['.', '..']);
449
                        foreach ($tmp as $name) {
450
                            if (strpos($name, 'CourseArchiver_') === 0 && is_dir($base.'/'.$name)) {
451
                                $cands[] = $base.'/'.$name;
452
                            }
453
                        }
454
                    }
455
                    usort($cands, static function ($a, $b) {
456
                        return (filemtime($b) ?: 0) <=> (filemtime($a) ?: 0);
457
                    });
458
                    foreach ($cands as $dir) {
459
                        if (is_file($dir.'/course_info.dat') || is_dir($dir.'/document')) {
460
                            $this->dlog('resolveImportRoot: using sibling CourseArchiver', ['dir' => $dir]);
461
                            $this->course->resources['__meta']['archiver_root'] = rtrim($dir, '/');
462
463
                            return rtrim($dir, '/');
464
                        }
465
                    }
466
                    $this->dlog('resolveImportRoot: no sibling CourseArchiver found next to zip', ['base' => $base]);
467
                }
468
            }
469
470
            $scanBase = $this->getCourseBackupsBase();
471
            if (is_dir($scanBase)) {
472
                $cands = glob($scanBase.'/CourseArchiver_*', GLOB_ONLYDIR) ?: [];
473
                if (empty($cands)) {
474
                    $tmp = array_diff(scandir($scanBase) ?: [], ['.', '..']);
475
                    foreach ($tmp as $name) {
476
                        if (strpos($name, 'CourseArchiver_') === 0 && is_dir($scanBase.'/'.$name)) {
477
                            $cands[] = $scanBase.'/'.$name;
478
                        }
479
                    }
480
                }
481
                usort($cands, static function ($a, $b) {
482
                    return (filemtime($b) ?: 0) <=> (filemtime($a) ?: 0);
483
                });
484
                foreach ($cands as $dir) {
485
                    if (is_file($dir.'/course_info.dat') || is_dir($dir.'/document')) {
486
                        $this->dlog('resolveImportRoot: using scanned CourseArchiver', ['dir' => $dir, 'scanBase' => $scanBase]);
487
                        $this->course->resources['__meta']['archiver_root'] = rtrim($dir, '/');
488
489
                        return rtrim($dir, '/');
490
                    }
491
                }
492
            }
493
494
            $this->dlog('resolveImportRoot: no valid import root found, falling back to copy mode');
495
496
            return '';
497
        };
498
499
        $backupRoot = $resolveImportRoot();
500
        $copyMode   = $backupRoot === '';
501
        $srcRoot    = $copyMode ? null : ($backupRoot.'/');
502
503
        $this->dlog('restore_documents: begin', [
504
            'files'   => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []),
505
            'session' => (int) $session_id,
506
            'mode'    => $copyMode ? 'copy' : 'import',
507
            'srcRoot' => $srcRoot,
508
        ]);
509
510
        $DBG = function (string $msg, array $ctx = []): void {
511
            error_log('[RESTORE:DOCS] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx)));
512
        };
513
514
        // Helper: returns the logical path from source CDocument (starts with "/")
515
        $getLogicalPathFromSource = function ($sourceId) use ($docRepo): string {
516
            $doc = $docRepo->find((int) $sourceId);
517
            if ($doc && method_exists($doc, 'getPath')) {
518
                $p = (string) $doc->getPath();
519
520
                return $p !== '' && $p[0] === '/' ? $p : '/'.$p;
521
            }
522
523
            return '';
524
        };
525
526
        // Hide the /learning_path folder in Documents (draft visibility on ResourceLink).
527
        $forceDraftVisibilityForLearningPathRoot = function (int $docIid) use ($docRepo, $courseEntity, $session, $group, $DBG): void {
528
            try {
529
                $doc = $docRepo->find($docIid);
530
                if (!$doc || !method_exists($doc, 'getResourceNode') || null === $doc->getResourceNode()) {
531
                    return;
532
                }
533
534
                $em = Container::getEntityManager();
535
                $rlRepo = $em->getRepository(ResourceLink::class);
536
537
                $link = $rlRepo->findOneBy([
538
                    'resourceNode' => $doc->getResourceNode(),
539
                    'course'       => $courseEntity,
540
                    'session'      => $session,
541
                    'group'        => $group,
542
                ]);
543
544
                if ($link && method_exists($link, 'getVisibility') && method_exists($link, 'setVisibility')) {
545
                    if ((int) $link->getVisibility() !== 0) {
546
                        $link->setVisibility(0);
547
                        $em->flush();
548
                        $DBG('learning_path.hidden', ['iid' => $docIid]);
549
                    }
550
                }
551
            } catch (\Throwable $e) {
552
                $DBG('learning_path.hide.failed', ['iid' => $docIid, 'err' => $e->getMessage()]);
553
            }
554
        };
555
556
        // Top-level folders we never want to import as visible Documents (even in import mode)
557
        $alwaysSkipTopFolders = [];
558
559
        // Reserved containers that must not leak into destination when copying
560
        $reservedTopFolders = ['certificates', 'learnpaths'];
561
562
        // Normalize any incoming "rel"
563
        $normalizeRel = function (string $rel) use ($copyMode): string {
564
            $rel = '/'.ltrim($rel, '/');
565
566
            while (preg_match('#^/document/#i', $rel)) {
567
                $rel = preg_replace('#^/document/#i', '/', $rel, 1);
568
            }
569
570
            if ($copyMode) {
571
                if (preg_match('#^/certificates/[^/]+/[^/]+(?:/(.*))?$#i', $rel, $m)) {
572
                    $rest = $m[1] ?? '';
573
574
                    return $rest === '' ? '/' : '/'.ltrim($rest, '/');
575
                }
576
                if (preg_match('#^/certificates/(.*)$#i', $rel, $m)) {
577
                    return '/'.ltrim($m[1], '/');
578
                }
579
            }
580
581
            if ($copyMode && preg_match('#^/([^/]+)/([^/]+)(?:/(.*))?$#', $rel, $m)) {
582
                $host   = $m[1];
583
                $course = $m[2];
584
                $rest   = $m[3] ?? '';
585
586
                $hostLooksLikeHostname = ($host === 'localhost') || str_contains($host, '.');
587
                $courseLooksLikeCode   = (bool) preg_match('/^[A-Za-z0-9_\-]{3,}$/', $course);
588
589
                if ($hostLooksLikeHostname && $courseLooksLikeCode) {
590
                    return $rest === '' ? '/' : '/'.ltrim($rest, '/');
591
                }
592
            }
593
594
            if ($copyMode && preg_match('#^/(?:learnpaths?|lp)/[^/]+/(.*)$#i', $rel, $m)) {
595
                return '/'.ltrim($m[1], '/');
596
            }
597
            if ($copyMode && preg_match('#^/(?:learnpaths?|lp)/(.*)$#i', $rel, $m)) {
598
                return '/'.ltrim($m[1], '/');
599
            }
600
601
            return $rel;
602
        };
603
604
        // Ensure a folder chain exists under Documents (skipping "document" as root)
605
        $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $session_id, $DBG) {
606
            $rel = '/'.ltrim($relPath, '/');
607
            if ('/' === $rel || '' === $rel) {
608
                return 0;
609
            }
610
611
            $parts = array_values(array_filter(explode('/', trim($rel, '/'))));
612
            $start = 0;
613
            if (isset($parts[0]) && 'document' === $parts[0]) {
614
                $start = 1;
615
            }
616
617
            $accum    = '';
618
            $parentId = 0;
619
620
            for ($i = $start; $i < \count($parts); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
621
                $seg   = $parts[$i];
622
                $accum = $accum.'/'.$seg;
623
624
                $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
625
                $title     = $seg;
626
627
                $existing = $docRepo->findCourseResourceByTitle(
628
                    $title,
629
                    $parentRes->getResourceNode(),
630
                    $courseEntity,
631
                    api_get_session_entity((int) $session_id),
632
                    api_get_group_entity(0)
633
                );
634
635
                if ($existing) {
636
                    $parentId = method_exists($existing, 'getIid') ? $existing->getIid() : 0;
637
                    continue;
638
                }
639
640
                $entity   = DocumentManager::addDocument(
641
                    ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']],
642
                    $accum,
643
                    'folder',
644
                    0,
645
                    $title,
646
                    null,
647
                    0,
648
                    null,
649
                    0,
650
                    (int) $session_id,
651
                    0,
652
                    false,
653
                    '',
654
                    $parentId,
655
                    ''
656
                );
657
                $parentId = method_exists($entity, 'getIid') ? $entity->getIid() : 0;
658
659
                $DBG('ensureFolder:create', ['accum' => $accum, 'iid' => $parentId]);
660
            }
661
662
            return $parentId;
663
        };
664
665
        // Robust HTML detection
666
        $isHtmlFile = function (string $filePath, string $nameGuess): bool {
667
            $ext1 = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
668
            $ext2 = strtolower(pathinfo($nameGuess, PATHINFO_EXTENSION));
669
            if (\in_array($ext1, ['html', 'htm'], true) || \in_array($ext2, ['html', 'htm'], true)) {
670
                return true;
671
            }
672
673
            $peek = (string) @file_get_contents($filePath, false, null, 0, 2048);
674
            if ($peek === '') {
675
                return false;
676
            }
677
678
            $s = strtolower($peek);
679
            if (str_contains($s, '<html') || str_contains($s, '<!doctype html')) {
680
                return true;
681
            }
682
683
            if (\function_exists('finfo_open')) {
684
                $fi = finfo_open(FILEINFO_MIME_TYPE);
685
                if ($fi) {
0 ignored issues
show
introduced by
$fi is of type resource, thus it always evaluated to false.
Loading history...
686
                    $mt = @finfo_buffer($fi, $peek) ?: '';
687
                    finfo_close($fi);
688
                    if (str_starts_with($mt, 'text/html')) {
689
                        return true;
690
                    }
691
                }
692
            }
693
694
            return false;
695
        };
696
697
        // -----------------------------
698
        // 1) Create folders first
699
        // -----------------------------
700
        $folders = [];
701
702
        foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) {
703
            if (FOLDER !== $item->file_type) {
704
                continue;
705
            }
706
707
            if ($copyMode && !empty($item->source_id)) {
708
                $rel = $getLogicalPathFromSource($item->source_id);
709
                if ($rel === '') {
710
                    $rel = '/'.ltrim(substr($item->path, 8), '/');
711
                }
712
            } else {
713
                $rel = '/'.ltrim(substr($item->path, 8), '/');
714
            }
715
716
            $rel = $normalizeRel($rel);
717
718
            if ($rel === '/' || $rel === '') {
719
                continue;
720
            }
721
722
            $firstSeg = strtolower((string) (explode('/', trim($rel, '/'))[0] ?? ''));
723
            if (\in_array($firstSeg, $alwaysSkipTopFolders, true)) {
724
                $this->dlog('restore_documents: skipping reserved folder', ['folder' => $firstSeg, 'rel' => $rel]);
725
                continue;
726
            }
727
728
            if ($copyMode && \in_array($firstSeg, $reservedTopFolders, true)) {
729
                continue;
730
            }
731
732
            $parts    = array_values(array_filter(explode('/', $rel)));
733
            $accum    = '';
734
            $parentId = 0;
735
736
            foreach ($parts as $i => $seg) {
737
                $accum .= '/'.$seg;
738
739
                if (isset($folders[$accum])) {
740
                    $parentId = $folders[$accum];
741
                    continue;
742
                }
743
744
                $parentResource = $parentId ? $docRepo->find($parentId) : $courseEntity;
745
                $title = $seg;
746
747
                if ($i === \count($parts) - 1 && !empty($item->title)) {
748
                    $itemTitle = (string) $item->title;
749
                    if (0 === strcasecmp($itemTitle, $seg)) {
750
                        $title = $itemTitle;
751
                    }
752
                }
753
754
                $existing = $docRepo->findCourseResourceByTitle(
755
                    $title,
756
                    $parentResource->getResourceNode(),
757
                    $courseEntity,
758
                    $session,
759
                    $group
760
                );
761
762
                if ($existing) {
763
                    $iid = method_exists($existing, 'getIid') ? $existing->getIid() : 0;
764
                } else {
765
                    $entity = DocumentManager::addDocument(
766
                        ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']],
767
                        $accum,
768
                        'folder',
769
                        0,
770
                        $title,
771
                        null,
772
                        0,
773
                        null,
774
                        0,
775
                        (int) $session_id,
776
                        0,
777
                        false,
778
                        '',
779
                        $parentId,
780
                        ''
781
                    );
782
                    $iid = method_exists($entity, 'getIid') ? $entity->getIid() : 0;
783
                }
784
785
                $folders[$accum] = $iid;
786
                if ($i === \count($parts) - 1) {
787
                    $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid;
788
                }
789
                $parentId = $iid;
790
791
                if ('/learning_path' === strtolower($accum)) {
792
                    $forceDraftVisibilityForLearningPathRoot((int) $iid);
793
                }
794
            }
795
        }
796
797
        // -----------------------------
798
        // 2) Two-pass import:
799
        //    Pass A: non-HTML files (build URL maps)
800
        //    Pass B: HTML files (rewrite using URL maps)
801
        // -----------------------------
802
        $urlMapByRel  = [];
803
        $urlMapByBase = [];
804
805
        $addToMaps = function (string $srcRelKey, int $iid) use (&$urlMapByRel, &$urlMapByBase, $docRepo): void {
806
            if ($srcRelKey === '' || $iid <= 0) {
807
                return;
808
            }
809
810
            $doc = $docRepo->find($iid);
811
            if (!$doc) {
812
                return;
813
            }
814
815
            $url = $docRepo->getResourceFileUrl($doc);
816
            if (!$url) {
817
                return;
818
            }
819
820
            if (!isset($urlMapByRel[$srcRelKey])) {
821
                $urlMapByRel[$srcRelKey] = $url;
822
            }
823
824
            $base = basename($srcRelKey);
825
            if ($base !== '' && (!isset($urlMapByBase[$base]) || !$urlMapByBase[$base])) {
826
                $urlMapByBase[$base] = $url;
827
            }
828
        };
829
830
        $resolveSrcPath = function ($item) use ($copyMode, $srcRoot, $docRepo): ?string {
831
            if ($copyMode) {
832
                $srcDoc = null;
833
                if (!empty($item->source_id)) {
834
                    $srcDoc = $docRepo->find((int) $item->source_id);
835
                }
836
                if (!$srcDoc) {
837
                    return null;
838
                }
839
840
                return $this->resourceFileAbsPathFromDocument($srcDoc) ?: null;
841
            }
842
843
            $p = $srcRoot.(string) $item->path;
844
            if (is_file($p) && is_readable($p)) {
845
                return $p;
846
            }
847
848
            $altRoot = rtrim((string) ($this->course->resources['__meta']['archiver_root'] ?? ''), '/').'/';
849
            if ($altRoot && $altRoot !== $srcRoot) {
850
                $p2 = $altRoot.(string) $item->path;
851
                if (is_file($p2) && is_readable($p2)) {
852
                    return $p2;
853
                }
854
            }
855
856
            return null;
857
        };
858
859
        $processOne = function (int $k, $item, bool $wantHtml) use (
860
            $copyMode,
861
            $docRepo,
862
            $courseEntity,
863
            $courseInfo,
864
            $session,
865
            $group,
866
            $session_id,
867
            $folders,
868
            $ensureFolder,
869
            $getLogicalPathFromSource,
870
            $normalizeRel,
871
            $reservedTopFolders,
872
            $alwaysSkipTopFolders,
873
            $isHtmlFile,
874
            &$urlMapByRel,
875
            &$urlMapByBase,
876
            $addToMaps,
877
            $resolveSrcPath,
878
            $DBG
879
        ): void {
880
            if (DOCUMENT !== $item->file_type) {
881
                return;
882
            }
883
884
            $srcRelKey = (string) ($item->path ?? '');
885
886
            // If already restored earlier, reuse and add to maps
887
            if (!empty($item->destination_id)) {
888
                $existing = $docRepo->find((int) $item->destination_id);
889
                if ($existing) {
890
                    $addToMaps($srcRelKey, (int) $item->destination_id);
891
892
                    return;
893
                }
894
                $item->destination_id = 0;
895
            }
896
897
            $srcPath = $resolveSrcPath($item);
898
            if (!$srcPath || !is_file($srcPath) || !is_readable($srcPath)) {
899
                return;
900
            }
901
902
            $rawTitle = $item->title ?: basename((string) $item->path);
903
            $isHtml = $isHtmlFile($srcPath, (string) $rawTitle);
904
905
            if ($wantHtml !== $isHtml) {
906
                return;
907
            }
908
909
            // Build destination rel path
910
            if ($copyMode && !empty($item->source_id)) {
911
                $rel = $getLogicalPathFromSource($item->source_id);
912
                if ($rel === '') {
913
                    $rel = '/'.ltrim(substr((string) $item->path, 8), '/');
914
                }
915
            } else {
916
                $rel = '/'.ltrim(substr((string) $item->path, 8), '/');
917
            }
918
919
            $rel = $normalizeRel($rel);
920
921
            $firstSeg = strtolower((string) (explode('/', trim($rel, '/'))[0] ?? ''));
922
            if (\in_array($firstSeg, $alwaysSkipTopFolders, true)) {
923
                return;
924
            }
925
            if ($copyMode && \in_array($firstSeg, $reservedTopFolders, true)) {
926
                // In copy mode we do not import these reserved roots as visible docs
927
                return;
928
            }
929
930
            $parentRel = rtrim(\dirname($rel), '/');
931
            $parentId  = $folders[$parentRel] ?? 0;
932
            if (!$parentId) {
933
                $parentId = $ensureFolder($parentRel);
934
                $folders[$parentRel] = $parentId;
935
            }
936
937
            $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
938
939
            $baseTitle  = (string) $rawTitle;
940
            $finalTitle = $baseTitle;
941
942
            $findExisting = function (string $t) use ($docRepo, $parentRes, $courseEntity, $session, $group) {
943
                $e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group);
944
945
                return $e && method_exists($e, 'getIid') ? (int) $e->getIid() : null;
946
            };
947
948
            $existsIid = $findExisting($finalTitle);
949
            if ($existsIid) {
950
                if (\defined('FILE_SKIP') && FILE_SKIP === (int) $this->file_option) {
951
                    $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = (int) $existsIid;
952
                    $addToMaps($srcRelKey, (int) $existsIid);
953
954
                    return;
955
                }
956
957
                // Rename on collision
958
                $pi   = pathinfo($baseTitle);
959
                $name = $pi['filename'] ?? $baseTitle;
960
                $ext2 = (isset($pi['extension']) && $pi['extension'] !== '') ? '.'.$pi['extension'] : '';
961
                $i    = 1;
962
963
                while ($findExisting($finalTitle)) {
964
                    $finalTitle = $name.'_'.$i.$ext2;
965
                    $i++;
966
                }
967
            }
968
969
            // IMPORTANT: keep the path consistent with the final title
970
            if (basename($rel) !== $finalTitle) {
971
                $rel = ($parentRel === '' || $parentRel === '/')
972
                    ? '/'.$finalTitle
973
                    : $parentRel.'/'.$finalTitle;
974
            }
975
976
            $content  = '';
977
            $realPath = '';
978
979
            if ($isHtml) {
980
                $raw = @file_get_contents($srcPath) ?: '';
981
                if (\defined('UTF8_CONVERT') && UTF8_CONVERT) {
982
                    $raw = utf8_encode($raw);
983
                }
984
985
                $courseDir = (string) ($courseInfo['directory'] ?? $courseInfo['code'] ?? '');
986
987
                $rew = ChamiloHelper::rewriteLegacyCourseUrlsWithMap(
988
                    $raw,
989
                    $courseDir,
990
                    $urlMapByRel,
991
                    $urlMapByBase
992
                );
993
994
                $content = $rew['html'];
995
996
                $DBG('html.rewrite', [
997
                    'file' => $finalTitle,
998
                    'replaced' => (int) ($rew['replaced'] ?? 0),
999
                    'misses' => (int) ($rew['misses'] ?? 0),
1000
                ]);
1001
            } else {
1002
                $realPath = $srcPath;
1003
            }
1004
1005
            $entity = DocumentManager::addDocument(
1006
                ['real_id' => $courseInfo['real_id'], 'code' => $courseInfo['code']],
1007
                $rel,
1008
                'file',
1009
                (int) ($item->size ?? 0),
1010
                $finalTitle,
1011
                $item->comment ?? '',
1012
                0,
1013
                null,
1014
                0,
1015
                (int) $session_id,
1016
                0,
1017
                false,
1018
                $content,
1019
                $parentId,
1020
                $realPath
1021
            );
1022
1023
            $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
1024
1025
            $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid;
1026
            $addToMaps($srcRelKey, $iid);
1027
        };
1028
1029
        // Pass A: non-HTML
1030
        foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) {
1031
            $processOne((int) $k, $item, false);
1032
        }
1033
1034
        // Pass B: HTML
1035
        foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) {
1036
            $processOne((int) $k, $item, true);
1037
        }
1038
1039
        $this->dlog('restore_documents: end', [
1040
            'mapByRel' => \count($urlMapByRel),
1041
            'mapByBase' => \count($urlMapByBase),
1042
        ]);
1043
    }
1044
1045
    /**
1046
     * Restore forum categories in the destination course.
1047
     *
1048
     * @param mixed $session_id
1049
     * @param mixed $respect_base_content
1050
     * @param mixed $destination_course_code
1051
     */
1052
    public function restore_forum_category($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void
1053
    {
1054
        $bag = $this->course->resources['Forum_Category']
1055
            ?? $this->course->resources['forum_category']
1056
            ?? [];
1057
1058
        if (empty($bag)) {
1059
            $this->dlog('restore_forum_category: empty bag');
1060
1061
            return;
1062
        }
1063
1064
        $em = Database::getManager();
1065
        $catRepo = Container::getForumCategoryRepository();
1066
        $course = api_get_course_entity($this->destination_course_id);
1067
        $session = api_get_session_entity((int) $session_id);
1068
1069
        foreach ($bag as $id => $res) {
1070
            if (!empty($res->destination_id)) {
1071
                continue;
1072
            }
1073
1074
            $obj = \is_object($res->obj ?? null) ? $res->obj : (object) [];
1075
            $title = (string) ($obj->cat_title ?? $obj->title ?? "Forum category #$id");
1076
            $comment = (string) ($obj->cat_comment ?? $obj->description ?? '');
1077
1078
            $comment = $this->rewriteHtmlForCourse($comment, (int) $session_id, '[forums.cat]');
1079
1080
            $existing = $catRepo->findOneBy(['title' => $title, 'resourceNode.parent' => $course->getResourceNode()]);
1081
            if ($existing) {
1082
                $destIid = (int) $existing->getIid();
1083
                $this->course->resources['Forum_Category'][$id] ??= new stdClass();
1084
                $this->course->resources['Forum_Category'][$id]->destination_id = $destIid;
1085
                $this->dlog('restore_forum_category: reuse existing', ['title' => $title, 'iid' => $destIid]);
1086
1087
                continue;
1088
            }
1089
1090
            $cat = (new CForumCategory())
1091
                ->setTitle($title)
1092
                ->setCatComment($comment)
1093
                ->setParent($course)
1094
                ->addCourseLink($course, $session)
1095
            ;
1096
1097
            $catRepo->create($cat);
1098
            $em->flush();
1099
1100
            $this->course->resources['Forum_Category'][$id] ??= new stdClass();
1101
            $this->course->resources['Forum_Category'][$id]->destination_id = (int) $cat->getIid();
1102
            $this->dlog('restore_forum_category: created', ['title' => $title, 'iid' => (int) $cat->getIid()]);
1103
        }
1104
1105
        $this->dlog('restore_forum_category: done', ['count' => \count($bag)]);
1106
    }
1107
1108
    /**
1109
     * Restore forums and their topics/posts.
1110
     */
1111
    public function restore_forums(int $sessionId = 0): void
1112
    {
1113
        $forumsBag = $this->course->resources['forum'] ?? [];
1114
        if (empty($forumsBag)) {
1115
            $this->dlog('restore_forums: empty forums bag');
1116
1117
            return;
1118
        }
1119
1120
        $em = Database::getManager();
1121
        $catRepo = Container::getForumCategoryRepository();
1122
        $forumRepo = Container::getForumRepository();
1123
1124
        $course = api_get_course_entity($this->destination_course_id);
1125
        $session = api_get_session_entity($sessionId);
1126
1127
        $catBag = $this->course->resources['Forum_Category'] ?? $this->course->resources['forum_category'] ?? [];
1128
        $catMap = [];
1129
1130
        if (!empty($catBag)) {
1131
            foreach ($catBag as $srcCatId => $res) {
1132
                if ((int) $res->destination_id > 0) {
1133
                    $catMap[(int) $srcCatId] = (int) $res->destination_id;
1134
1135
                    continue;
1136
                }
1137
1138
                $obj = \is_object($res->obj ?? null) ? $res->obj : (object) [];
1139
                $title = (string) ($obj->cat_title ?? $obj->title ?? "Forum category #$srcCatId");
1140
                $comment = (string) ($obj->cat_comment ?? $obj->description ?? '');
1141
1142
                $comment = $this->rewriteHtmlForCourse($comment, (int) $sessionId, '[forums.cat@forums]');
1143
1144
                $cat = (new CForumCategory())
1145
                    ->setTitle($title)
1146
                    ->setCatComment($comment)
1147
                    ->setParent($course)
1148
                    ->addCourseLink($course, $session)
1149
                ;
1150
1151
                $catRepo->create($cat);
1152
                $em->flush();
1153
1154
                $destIid = (int) $cat->getIid();
1155
                $catMap[(int) $srcCatId] = $destIid;
1156
1157
                $this->course->resources['Forum_Category'][$srcCatId] ??= new stdClass();
1158
                $this->course->resources['Forum_Category'][$srcCatId]->destination_id = $destIid;
1159
1160
                $this->dlog('restore_forums: created category', [
1161
                    'src_id' => (int) $srcCatId, 'iid' => $destIid, 'title' => $title,
1162
                ]);
1163
            }
1164
        }
1165
1166
        $defaultCategory = null;
1167
        $ensureDefault = function () use (&$defaultCategory, $course, $session, $catRepo, $em): CForumCategory {
1168
            if ($defaultCategory instanceof CForumCategory) {
1169
                return $defaultCategory;
1170
            }
1171
            $defaultCategory = (new CForumCategory())
1172
                ->setTitle('General')
1173
                ->setCatComment('')
1174
                ->setParent($course)
1175
                ->addCourseLink($course, $session)
1176
            ;
1177
            $catRepo->create($defaultCategory);
1178
            $em->flush();
1179
1180
            return $defaultCategory;
1181
        };
1182
1183
        foreach ($forumsBag as $srcForumId => $forumRes) {
1184
            if (!\is_object($forumRes) || !\is_object($forumRes->obj)) {
1185
                continue;
1186
            }
1187
1188
            if ((int) ($forumRes->destination_id ?? 0) > 0) {
1189
                $this->dlog('restore_forums: already mapped, skipping', [
1190
                    'src_forum_id' => (int) $srcForumId,
1191
                    'dst_forum_iid' => (int) $forumRes->destination_id,
1192
                ]);
1193
                continue;
1194
            }
1195
1196
            $p = (array) $forumRes->obj;
1197
1198
            $dstCategory = null;
1199
            $srcCatId = (int) ($p['forum_category'] ?? 0);
1200
            if ($srcCatId > 0 && isset($catMap[$srcCatId])) {
1201
                $dstCategory = $catRepo->find($catMap[$srcCatId]);
1202
            }
1203
            if (!$dstCategory && 1 === \count($catMap)) {
1204
                $onlyDestIid = (int) reset($catMap);
1205
                $dstCategory = $catRepo->find($onlyDestIid);
1206
            }
1207
            if (!$dstCategory) {
1208
                $dstCategory = $ensureDefault();
1209
            }
1210
1211
            $forumComment = (string) ($p['forum_comment'] ?? '');
1212
            $forumComment = $this->rewriteHtmlForCourse($forumComment, (int) $sessionId, '[forums.forum]');
1213
1214
            $forum = (new CForum())
1215
                ->setTitle($p['forum_title'] ?? ('Forum #'.$srcForumId))
1216
                ->setForumComment($forumComment)
1217
                ->setForumCategory($dstCategory)
1218
                ->setAllowAnonymous((int) ($p['allow_anonymous'] ?? 0))
1219
                ->setAllowEdit((int) ($p['allow_edit'] ?? 0))
1220
                ->setApprovalDirectPost((string) ($p['approval_direct_post'] ?? '0'))
1221
                ->setAllowAttachments((int) ($p['allow_attachments'] ?? 1))
1222
                ->setAllowNewThreads((int) ($p['allow_new_threads'] ?? 1))
1223
                ->setDefaultView($p['default_view'] ?? 'flat')
1224
                ->setForumOfGroup((string) ($p['forum_of_group'] ?? 0))
1225
                ->setForumGroupPublicPrivate($p['forum_group_public_private'] ?? 'public')
1226
                ->setModerated((bool) ($p['moderated'] ?? false))
1227
                ->setStartTime(!empty($p['start_time']) && '0000-00-00 00:00:00' !== $p['start_time']
1228
                    ? api_get_utc_datetime($p['start_time'], true, true) : null)
1229
                ->setEndTime(!empty($p['end_time']) && '0000-00-00 00:00:00' !== $p['end_time']
1230
                    ? api_get_utc_datetime($p['end_time'], true, true) : null)
1231
                ->setParent($dstCategory ?: $course)
1232
                ->addCourseLink($course, $session)
1233
            ;
1234
1235
            $forumRepo->create($forum);
1236
            $em->flush();
1237
1238
            $this->course->resources['forum'][$srcForumId] ??= new stdClass();
1239
            $this->course->resources['forum'][$srcForumId]->destination_id = (int) $forum->getIid();
1240
            $this->dlog('restore_forums: created forum', [
1241
                'src_forum_id' => (int) $srcForumId,
1242
                'dst_forum_iid' => (int) $forum->getIid(),
1243
                'category_iid' => (int) $dstCategory->getIid(),
1244
            ]);
1245
1246
            $topicsBag = $this->course->resources['thread'] ?? [];
1247
            foreach ($topicsBag as $srcThreadId => $topicRes) {
1248
                if (!\is_object($topicRes) || !\is_object($topicRes->obj)) {
1249
                    continue;
1250
                }
1251
                if ((int) $topicRes->obj->forum_id === (int) $srcForumId) {
1252
                    $tid = $this->restore_topic((int) $srcThreadId, (int) $forum->getIid(), $sessionId);
1253
                    $this->dlog('restore_forums: topic restored', [
1254
                        'src_thread_id' => (int) $srcThreadId,
1255
                        'dst_thread_iid' => (int) ($tid ?? 0),
1256
                        'dst_forum_iid' => (int) $forum->getIid(),
1257
                    ]);
1258
                }
1259
            }
1260
        }
1261
1262
        $this->dlog('restore_forums: done', ['forums' => \count($forumsBag)]);
1263
    }
1264
1265
    /**
1266
     * Restore a forum topic (thread).
1267
     */
1268
    public function restore_topic(int $srcThreadId, int $dstForumId, int $sessionId = 0): ?int
1269
    {
1270
        $topicsBag = $this->course->resources['thread'] ?? [];
1271
        $topicRes = $topicsBag[$srcThreadId] ?? null;
1272
        if (!$topicRes || !\is_object($topicRes->obj)) {
1273
            $this->dlog('restore_topic: missing topic object', ['src_thread_id' => $srcThreadId]);
1274
1275
            return null;
1276
        }
1277
1278
        $em = Database::getManager();
1279
        $forumRepo = Container::getForumRepository();
1280
        $threadRepo = Container::getForumThreadRepository();
1281
        $postRepo = Container::getForumPostRepository();
1282
1283
        $course = api_get_course_entity($this->destination_course_id);
1284
        $session = api_get_session_entity((int) $sessionId);
1285
        $user = api_get_user_entity($this->first_teacher_id);
1286
1287
        /** @var CForum|null $forum */
1288
        $forum = $forumRepo->find($dstForumId);
1289
        if (!$forum) {
1290
            $this->dlog('restore_topic: destination forum not found', ['dst_forum_id' => $dstForumId]);
1291
1292
            return null;
1293
        }
1294
1295
        $p = (array) $topicRes->obj;
1296
1297
        $thread = (new CForumThread())
1298
            ->setTitle((string) ($p['thread_title'] ?? "Thread #$srcThreadId"))
1299
            ->setForum($forum)
1300
            ->setUser($user)
1301
            ->setThreadDate(new DateTime(api_get_utc_datetime(), new DateTimeZone('UTC')))
1302
            ->setThreadSticky((bool) ($p['thread_sticky'] ?? false))
1303
            ->setThreadTitleQualify((string) ($p['thread_title_qualify'] ?? ''))
1304
            ->setThreadQualifyMax((float) ($p['thread_qualify_max'] ?? 0))
1305
            ->setThreadWeight((float) ($p['thread_weight'] ?? 0))
1306
            ->setThreadPeerQualify((bool) ($p['thread_peer_qualify'] ?? false))
1307
            ->setParent($forum)
1308
            ->addCourseLink($course, $session)
1309
        ;
1310
1311
        $threadRepo->create($thread);
1312
        $em->flush();
1313
1314
        $this->course->resources['thread'][$srcThreadId] ??= new stdClass();
1315
        $this->course->resources['thread'][$srcThreadId]->destination_id = (int) $thread->getIid();
1316
        $this->dlog('restore_topic: created', [
1317
            'src_thread_id' => $srcThreadId,
1318
            'dst_thread_iid' => (int) $thread->getIid(),
1319
            'dst_forum_iid' => (int) $forum->getIid(),
1320
        ]);
1321
1322
        $postsBag = $this->course->resources['post'] ?? [];
1323
        foreach ($postsBag as $srcPostId => $postRes) {
1324
            if (!\is_object($postRes) || !\is_object($postRes->obj)) {
1325
                continue;
1326
            }
1327
            if ((int) $postRes->obj->thread_id === (int) $srcThreadId) {
1328
                $pid = $this->restore_post((int) $srcPostId, (int) $thread->getIid(), (int) $forum->getIid(), $sessionId);
1329
                $this->dlog('restore_topic: post restored', [
1330
                    'src_post_id' => (int) $srcPostId,
1331
                    'dst_post_iid' => (int) ($pid ?? 0),
1332
                ]);
1333
            }
1334
        }
1335
1336
        $last = $postRepo->findOneBy(['thread' => $thread], ['postDate' => 'DESC']);
1337
        if ($last) {
1338
            $thread->setThreadLastPost($last);
1339
            $em->persist($thread);
1340
            $em->flush();
1341
        }
1342
1343
        return (int) $thread->getIid();
1344
    }
1345
1346
    /**
1347
     * Restore a forum post.
1348
     */
1349
    public function restore_post(int $srcPostId, int $dstThreadId, int $dstForumId, int $sessionId = 0): ?int
1350
    {
1351
        $postsBag = $this->course->resources['post'] ?? [];
1352
        $postRes = $postsBag[$srcPostId] ?? null;
1353
        if (!$postRes || !\is_object($postRes->obj)) {
1354
            $this->dlog('restore_post: missing post object', ['src_post_id' => $srcPostId]);
1355
1356
            return null;
1357
        }
1358
1359
        $em = Database::getManager();
1360
        $forumRepo = Container::getForumRepository();
1361
        $threadRepo = Container::getForumThreadRepository();
1362
        $postRepo = Container::getForumPostRepository();
1363
1364
        $course = api_get_course_entity($this->destination_course_id);
1365
        $session = api_get_session_entity((int) $sessionId);
1366
        $user = api_get_user_entity($this->first_teacher_id);
1367
1368
        $thread = $threadRepo->find($dstThreadId);
1369
        $forum = $forumRepo->find($dstForumId);
1370
        if (!$thread || !$forum) {
1371
            $this->dlog('restore_post: destination thread/forum not found', [
1372
                'dst_thread_id' => $dstThreadId,
1373
                'dst_forum_id' => $dstForumId,
1374
            ]);
1375
1376
            return null;
1377
        }
1378
1379
        $p = (array) $postRes->obj;
1380
1381
        $postText = (string) ($p['post_text'] ?? '');
1382
        $postText = $this->rewriteHtmlForCourse($postText, (int) $sessionId, '[forums.post]');
1383
1384
        $post = (new CForumPost())
1385
            ->setTitle((string) ($p['post_title'] ?? "Post #$srcPostId"))
1386
            ->setPostText($postText)
1387
            ->setThread($thread)
1388
            ->setForum($forum)
1389
            ->setUser($user)
1390
            ->setPostDate(new DateTime(api_get_utc_datetime(), new DateTimeZone('UTC')))
1391
            ->setPostNotification((bool) ($p['post_notification'] ?? false))
1392
            ->setVisible(true)
1393
            ->setStatus(CForumPost::STATUS_VALIDATED)
1394
            ->setParent($thread)
1395
            ->addCourseLink($course, $session)
1396
        ;
1397
1398
        if (!empty($p['post_parent_id'])) {
1399
            $parentDestId = (int) ($postsBag[$p['post_parent_id']]->destination_id ?? 0);
1400
            if ($parentDestId > 0) {
1401
                $parent = $postRepo->find($parentDestId);
1402
                if ($parent) {
1403
                    $post->setPostParent($parent);
1404
                }
1405
            }
1406
        }
1407
1408
        $postRepo->create($post);
1409
        $em->flush();
1410
1411
        $this->course->resources['post'][$srcPostId] ??= new stdClass();
1412
        $this->course->resources['post'][$srcPostId]->destination_id = (int) $post->getIid();
1413
        $this->dlog('restore_post: created', [
1414
            'src_post_id' => (int) $srcPostId,
1415
            'dst_post_iid' => (int) $post->getIid(),
1416
            'dst_thread_id' => (int) $thread->getIid(),
1417
            'dst_forum_id' => (int) $forum->getIid(),
1418
        ]);
1419
1420
        return (int) $post->getIid();
1421
    }
1422
1423
    /**
1424
     * Restore a link category.
1425
     *
1426
     * @param mixed $id
1427
     * @param mixed $sessionId
1428
     */
1429
    public function restore_link_category($id, $sessionId = 0): int
1430
    {
1431
        $sessionId = (int) $sessionId;
1432
1433
        if (0 === (int) $id) {
1434
            $this->dlog('restore_link_category: source category is 0 (no category), returning 0');
1435
1436
            return 0;
1437
        }
1438
1439
        $resources = $this->course->resources ?? [];
1440
1441
        // Resolve the actual bucket key present in this backup
1442
        $candidateKeys = ['link_category', 'Link_Category'];
1443
        if (\defined('RESOURCE_LINKCATEGORY') && RESOURCE_LINKCATEGORY) {
1444
            $candidateKeys[] = (string) RESOURCE_LINKCATEGORY;
1445
        }
1446
1447
        $catKey = null;
1448
        foreach ($candidateKeys as $k) {
1449
            if (isset($resources[$k]) && \is_array($resources[$k])) {
1450
                $catKey = $k;
1451
1452
                break;
1453
            }
1454
        }
1455
1456
        if (null === $catKey) {
1457
            $this->dlog('restore_link_category: no category bucket in course->resources');
1458
1459
            return 0;
1460
        }
1461
1462
        // Locate the category wrapper by 3 strategies: array key, wrapper->source_id, inner obj->id
1463
        $bucket = $resources[$catKey];
1464
1465
        // by integer array key
1466
        $byIntKey = [];
1467
        foreach ($bucket as $k => $wrap) {
1468
            $ik = is_numeric($k) ? (int) $k : 0;
1469
            if ($ik > 0) {
1470
                $byIntKey[$ik] = $wrap;
1471
            }
1472
        }
1473
1474
        // by wrapper->source_id
1475
        $bySourceId = [];
1476
        foreach ($bucket as $wrap) {
1477
            if (!\is_object($wrap)) {
1478
                continue;
1479
            }
1480
            $sid = isset($wrap->source_id) ? (int) $wrap->source_id : 0;
1481
            if ($sid > 0) {
1482
                $bySourceId[$sid] = $wrap;
1483
            }
1484
        }
1485
1486
        // by inner entity id (obj->id)
1487
        $byObjId = [];
1488
        foreach ($bucket as $wrap) {
1489
            if (\is_object($wrap) && isset($wrap->obj) && \is_object($wrap->obj)) {
1490
                $oid = isset($wrap->obj->id) ? (int) $wrap->obj->id : 0;
1491
                if ($oid > 0) {
1492
                    $byObjId[$oid] = $wrap;
1493
                }
1494
            }
1495
        }
1496
1497
        $iid = (int) $id;
1498
        $srcCat = $byIntKey[$iid]
1499
            ?? $bySourceId[$iid]
1500
            ?? $byObjId[$iid]
1501
            ?? ($bucket[(string) $id] ?? ($bucket[$id] ?? null));
1502
1503
        if (!\is_object($srcCat)) {
1504
            $this->dlog('restore_link_category: source category object not found', [
1505
                'asked_id' => $iid,
1506
                'bucket' => $catKey,
1507
                'keys_seen' => \array_slice(array_keys((array) $bucket), 0, 12),
1508
                'index_hit' => [
1509
                    'byIntKey' => isset($byIntKey[$iid]),
1510
                    'bySourceId' => isset($bySourceId[$iid]),
1511
                    'byObjId' => isset($byObjId[$iid]),
1512
                ],
1513
            ]);
1514
1515
            return 0;
1516
        }
1517
1518
        // Already mapped?
1519
        if ((int) $srcCat->destination_id > 0) {
1520
            return (int) $srcCat->destination_id;
1521
        }
1522
1523
        // Unwrap/normalize fields
1524
        $e = (isset($srcCat->obj) && \is_object($srcCat->obj)) ? $srcCat->obj : $srcCat;
1525
        $title = trim((string) ($e->title ?? $e->category_title ?? ($srcCat->extra['title'] ?? '') ?? ''));
1526
        if ('' === $title) {
1527
            $title = 'Links';
1528
        }
1529
        $description = (string) ($e->description ?? ($srcCat->extra['description'] ?? '') ?? '');
1530
1531
        $em = Database::getManager();
1532
        $catRepo = Container::getLinkCategoryRepository();
1533
        $course = api_get_course_entity($this->destination_course_id);
1534
        $session = api_get_session_entity((int) $sessionId);
1535
1536
        // Look for an existing category under the same course (by title)
1537
        $existing = null;
1538
        $candidates = $catRepo->findBy(['title' => $title]);
1539
        if (!empty($candidates)) {
1540
            $courseNode = $course->getResourceNode();
1541
            foreach ($candidates as $cand) {
1542
                $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1543
                $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1544
                if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) {
1545
                    $existing = $cand;
1546
1547
                    break;
1548
                }
1549
            }
1550
        }
1551
1552
        if ($existing) {
1553
            if (FILE_SKIP === $this->file_option) {
1554
                $destIid = (int) $existing->getIid();
1555
                // Write back to the SAME wrapper we located
1556
                $srcCat->destination_id = $destIid;
1557
                $this->dlog('restore_link_category: reuse (SKIP)', [
1558
                    'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title,
1559
                ]);
1560
1561
                return $destIid;
1562
            }
1563
1564
            if (FILE_OVERWRITE === $this->file_option) {
1565
                $existing->setDescription($description);
1566
                if (method_exists($existing, 'setParent')) {
1567
                    $existing->setParent($course);
1568
                }
1569
                if (method_exists($existing, 'addCourseLink')) {
1570
                    $existing->addCourseLink($course, $session);
1571
                }
1572
                $em->persist($existing);
1573
                $em->flush();
1574
1575
                $destIid = (int) $existing->getIid();
1576
                $srcCat->destination_id = $destIid;
1577
                $this->dlog('restore_link_category: overwrite', [
1578
                    'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title,
1579
                ]);
1580
1581
                return $destIid;
1582
            }
1583
1584
            // FILE_RENAME policy
1585
            $base = $title;
1586
            $i = 1;
1587
            $exists = true;
1588
            do {
1589
                $title = $base.' ('.$i++.')';
1590
                $exists = false;
1591
                foreach ($catRepo->findBy(['title' => $title]) as $cand) {
1592
                    $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1593
                    $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1594
                    if ($parent && $parent->getId() === $course->getResourceNode()->getId()) {
1595
                        $exists = true;
1596
1597
                        break;
1598
                    }
1599
                }
1600
            } while ($exists);
1601
        }
1602
1603
        // Create new category
1604
        $cat = (new CLinkCategory())
1605
            ->setTitle($title)
1606
            ->setDescription($description)
1607
        ;
1608
1609
        if (method_exists($cat, 'setParent')) {
1610
            $cat->setParent($course);
1611
        }
1612
        if (method_exists($cat, 'addCourseLink')) {
1613
            $cat->addCourseLink($course, $session);
1614
        }
1615
1616
        $em->persist($cat);
1617
        $em->flush();
1618
1619
        $destIid = (int) $cat->getIid();
1620
1621
        // Write back to the SAME wrapper we located (object is by reference)
1622
        $srcCat->destination_id = $destIid;
1623
1624
        $this->dlog('restore_link_category: created', [
1625
            'src_cat_id' => $iid, 'dst_cat_id' => $destIid, 'title' => $title, 'bucket' => $catKey,
1626
        ]);
1627
1628
        return $destIid;
1629
    }
1630
1631
    /**
1632
     * Restore course links.
1633
     *
1634
     * @param mixed $session_id
1635
     */
1636
    public function restore_links($session_id = 0): void
1637
    {
1638
        if (!$this->course->has_resources(RESOURCE_LINK)) {
1639
            return;
1640
        }
1641
1642
        $resources = $this->course->resources;
1643
        $count = \is_array($resources[RESOURCE_LINK] ?? null) ? \count($resources[RESOURCE_LINK]) : 0;
1644
1645
        $this->dlog('restore_links: begin', ['count' => $count]);
1646
1647
        $em = Database::getManager();
1648
        $linkRepo = Container::getLinkRepository();
1649
        $catRepo = Container::getLinkCategoryRepository();
1650
        $course = api_get_course_entity($this->destination_course_id);
1651
        $session = api_get_session_entity((int) $session_id);
1652
1653
        // Safe duplicate finder (no dot-path in criteria; filter parent in PHP)
1654
        $findDuplicate = function (string $t, string $u, ?CLinkCategory $cat) use ($linkRepo, $course) {
1655
            $criteria = ['title' => $t, 'url' => $u, 'category' => ($cat instanceof CLinkCategory ? $cat : null)];
1656
            $candidates = $linkRepo->findBy($criteria);
1657
            if (empty($candidates)) {
1658
                return null;
1659
            }
1660
            $courseNode = $course->getResourceNode();
1661
            foreach ($candidates as $cand) {
1662
                $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1663
                $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1664
                if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) {
1665
                    return $cand;
1666
                }
1667
            }
1668
1669
            return null;
1670
        };
1671
1672
        foreach ($resources[RESOURCE_LINK] as $oldLinkId => $link) {
1673
1674
            $mapped = (int) ($this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id ?? 0);
1675
            if ($mapped > 0) {
1676
                $this->dlog('restore_links: already mapped, skipping', [
1677
                    'src_link_id' => (int) $oldLinkId,
1678
                    'dst_link_id' => $mapped,
1679
                ]);
1680
                continue;
1681
            }
1682
1683
            $rawUrl = (string) ($link->url ?? ($link->extra['url'] ?? ''));
1684
            $rawTitle = (string) ($link->title ?? ($link->extra['title'] ?? ''));
1685
            $rawDesc = (string) ($link->description ?? ($link->extra['description'] ?? ''));
1686
            $target = isset($link->target) ? (string) $link->target : null;
1687
1688
            // Prefer plain category_id, fallback to linked_resources if needed
1689
            $catSrcId = (int) ($link->category_id ?? 0);
1690
            if ($catSrcId <= 0 && isset($link->linked_resources['Link_Category'][0])) {
1691
                $catSrcId = (int) $link->linked_resources['Link_Category'][0];
1692
            }
1693
            if ($catSrcId <= 0 && isset($link->linked_resources['link_category'][0])) {
1694
                $catSrcId = (int) $link->linked_resources['link_category'][0];
1695
            }
1696
1697
            $onHome = (bool) ($link->on_homepage ?? false);
1698
1699
            $url = trim($rawUrl);
1700
            $title = '' !== trim($rawTitle) ? trim($rawTitle) : $url;
1701
1702
            if ('' === $url) {
1703
                $this->dlog('restore_links: skipped (empty URL)', [
1704
                    'src_link_id' => (int) $oldLinkId,
1705
                    'has_obj' => !empty($link->has_obj),
1706
                    'extra_keys' => isset($link->extra) ? implode(',', array_keys((array) $link->extra)) : '',
1707
                ]);
1708
1709
                continue;
1710
            }
1711
1712
            // Resolve / create destination category if source had one; otherwise null
1713
            $category = null;
1714
            if ($catSrcId > 0) {
1715
                $dstCatIid = (int) $this->restore_link_category($catSrcId, (int) $session_id);
1716
                if ($dstCatIid > 0) {
1717
                    $category = $catRepo->find($dstCatIid);
1718
                } else {
1719
                    $this->dlog('restore_links: category not available, using null', [
1720
                        'src_link_id' => (int) $oldLinkId,
1721
                        'src_cat_id' => (int) $catSrcId,
1722
                    ]);
1723
                }
1724
            }
1725
1726
            // Dedup (title + url + category in same course)
1727
            $existing = $findDuplicate($title, $url, $category);
1728
1729
            if ($existing) {
1730
                if (FILE_SKIP === $this->file_option) {
1731
                    $destIid = (int) $existing->getIid();
1732
                    $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass();
1733
                    $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1734
1735
                    $this->dlog('restore_links: reuse (SKIP)', [
1736
                        'src_link_id' => (int) $oldLinkId,
1737
                        'dst_link_id' => $destIid,
1738
                        'title' => $title,
1739
                        'url' => $url,
1740
                    ]);
1741
1742
                    continue;
1743
                }
1744
1745
                if (FILE_OVERWRITE === $this->file_option) {
1746
                    $descHtml = $this->rewriteHtmlForCourse($rawDesc, (int) $session_id, '[links.link.overwrite]');
1747
1748
                    $existing
1749
                        ->setUrl($url)
1750
                        ->setTitle($title)
1751
                        ->setDescription($descHtml)
1752
                        ->setTarget((string) ($target ?? ''))
1753
                    ;
1754
1755
                    if (method_exists($existing, 'setParent')) {
1756
                        $existing->setParent($course);
1757
                    }
1758
                    if (method_exists($existing, 'addCourseLink')) {
1759
                        $existing->addCourseLink($course, $session);
1760
                    }
1761
                    $existing->setCategory($category); // may be null
1762
1763
                    $em->persist($existing);
1764
                    $em->flush();
1765
1766
                    $destIid = (int) $existing->getIid();
1767
                    $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass();
1768
                    $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1769
1770
                    $this->dlog('restore_links: overwrite', [
1771
                        'src_link_id' => (int) $oldLinkId,
1772
                        'dst_link_id' => $destIid,
1773
                        'title' => $title,
1774
                        'url' => $url,
1775
                    ]);
1776
1777
                    continue;
1778
                }
1779
1780
                // FILE_RENAME flow
1781
                $base = $title;
1782
                $i = 1;
1783
                do {
1784
                    $title = $base.' ('.$i.')';
1785
                    $i++;
1786
                } while ($findDuplicate($title, $url, $category));
1787
            }
1788
1789
            $descHtml = $this->rewriteHtmlForCourse($rawDesc, (int) $session_id, '[links.link.create]');
1790
1791
            // Create new link entity
1792
            $entity = (new CLink())
1793
                ->setUrl($url)
1794
                ->setTitle($title)
1795
                ->setDescription($descHtml)
1796
                ->setTarget((string) ($target ?? ''))
1797
            ;
1798
1799
            if (method_exists($entity, 'setParent')) {
1800
                $entity->setParent($course);
1801
            }
1802
            if (method_exists($entity, 'addCourseLink')) {
1803
                $entity->addCourseLink($course, $session);
1804
            }
1805
1806
            if ($category instanceof CLinkCategory) {
1807
                $entity->setCategory($category);
1808
            }
1809
1810
            $em->persist($entity);
1811
            $em->flush();
1812
1813
            // Map destination id back into resources
1814
            $destIid = (int) $entity->getIid();
1815
            $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new stdClass();
1816
            $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1817
1818
            $this->dlog('restore_links: created', [
1819
                'src_link_id' => (int) $oldLinkId,
1820
                'dst_link_id' => $destIid,
1821
                'title' => $title,
1822
                'url' => $url,
1823
                'category' => $category ? $category->getTitle() : null,
1824
            ]);
1825
1826
            if (!empty($onHome)) {
1827
                try {
1828
                    $em->persist($entity);
1829
                    $em->flush();
1830
                } catch (Throwable $e) {
1831
                    error_log('COURSE_DEBUG: restore_links: homepage flag handling failed: '.$e->getMessage());
1832
                }
1833
            }
1834
        }
1835
1836
        $this->dlog('restore_links: end');
1837
    }
1838
1839
    /**
1840
     * Restore tool introductions.
1841
     *
1842
     * Accept multiple bucket spellings to be robust against controller normalization:
1843
     * - RESOURCE_TOOL_INTRO (if defined)
1844
     * - 'Tool introduction' (legacy)
1845
     * - 'tool_intro' / 'tool introduction' / 'tool_introduction'
1846
     *
1847
     * @param mixed $sessionId
1848
     */
1849
    public function restore_tool_intro($sessionId = 0): void
1850
    {
1851
        $resources = $this->course->resources ?? [];
1852
1853
        // Detect the right bucket key (be generous with aliases)
1854
        $bagKey = null;
1855
        $candidates = [];
1856
1857
        if (\defined('RESOURCE_TOOL_INTRO')) {
1858
            $candidates[] = RESOURCE_TOOL_INTRO;
1859
        }
1860
1861
        // Common spellings seen in exports / normalizers
1862
        $candidates = array_merge($candidates, [
1863
            'Tool introduction',
1864
            'tool introduction',
1865
            'tool_introduction',
1866
            'tool/intro',
1867
            'tool_intro',
1868
        ]);
1869
1870
        foreach ($candidates as $k) {
1871
            if (!empty($resources[$k]) && \is_array($resources[$k])) {
1872
                $bagKey = $k;
1873
                break;
1874
            }
1875
        }
1876
1877
        if (null === $bagKey) {
1878
            $this->dlog('restore_tool_intro: no matching bucket found', [
1879
                'available_keys' => array_keys((array) $resources),
1880
            ]);
1881
            return;
1882
        }
1883
1884
        $sessionId = (int) $sessionId;
1885
        $this->dlog('restore_tool_intro: begin', [
1886
            'bucket' => $bagKey,
1887
            'count'  => \count($resources[$bagKey]),
1888
        ]);
1889
1890
        $em      = Database::getManager();
1891
        $course  = api_get_course_entity($this->destination_course_id);
1892
        $session = $sessionId ? api_get_session_entity($sessionId) : null;
1893
1894
        $toolRepo   = $em->getRepository(Tool::class);
1895
        $cToolRepo  = $em->getRepository(CTool::class);
1896
        $introRepo  = $em->getRepository(CToolIntro::class);
1897
1898
        foreach ($resources[$bagKey] as $rawId => $tIntro) {
1899
            // Resolve tool key (id may be missing in some dumps)
1900
            $toolKey = trim((string) ($tIntro->id ?? ''));
1901
            if ('' === $toolKey || '0' === $toolKey) {
1902
                $toolKey = (string) $rawId;
1903
            }
1904
            $alias = strtolower($toolKey);
1905
1906
            // Normalize common aliases to platform keys
1907
            if ('homepage' === $alias || 'course_home' === $alias) {
1908
                $toolKey = 'course_homepage';
1909
            }
1910
1911
            $this->dlog('restore_tool_intro: resolving tool key', [
1912
                'raw_id'   => (string) $rawId,
1913
                'obj_id'   => isset($tIntro->id) ? (string) $tIntro->id : null,
1914
                'toolKey'  => $toolKey,
1915
            ]);
1916
1917
            // Already mapped?
1918
            $mapped = (int) ($tIntro->destination_id ?? 0);
1919
            if ($mapped > 0) {
1920
                $this->dlog('restore_tool_intro: already mapped, skipping', [
1921
                    'src_id' => $toolKey, 'dst_id' => $mapped,
1922
                ]);
1923
                continue;
1924
            }
1925
1926
            // Rewrite HTML using centralized helper (keeps document links consistent)
1927
            $introHtml = $this->rewriteHtmlForCourse((string) ($tIntro->intro_text ?? ''), $sessionId, '[tool_intro.intro]');
1928
1929
            // Find platform Tool entity by title; try a couple of fallbacks
1930
            $toolEntity = $toolRepo->findOneBy(['title' => $toolKey]);
1931
            if (!$toolEntity) {
1932
                // Fallbacks: lower/upper case attempts
1933
                $toolEntity = $toolRepo->findOneBy(['title' => strtolower($toolKey)])
1934
                    ?: $toolRepo->findOneBy(['title' => ucfirst(strtolower($toolKey))]);
1935
            }
1936
            if (!$toolEntity) {
1937
                $this->dlog('restore_tool_intro: missing Tool entity, skipping', ['tool' => $toolKey]);
1938
                continue;
1939
            }
1940
1941
            // Ensure a CTool exists for this course/session+tool
1942
            $cTool = $cToolRepo->findOneBy([
1943
                'course'  => $course,
1944
                'session' => $session,
1945
                'title'   => $toolKey,
1946
            ]);
1947
1948
            if (!$cTool) {
1949
                $cTool = (new CTool())
1950
                    ->setTool($toolEntity)
1951
                    ->setTitle($toolKey)
1952
                    ->setCourse($course)
1953
                    ->setSession($session)
1954
                    ->setPosition(1)
1955
                    ->setVisibility(true)
1956
                    ->setParent($course)
1957
                    ->setCreator($course->getCreator() ?? null)
1958
                    ->addCourseLink($course, $session);
1959
                $em->persist($cTool);
1960
                $em->flush();
1961
1962
                $this->dlog('restore_tool_intro: CTool created', [
1963
                    'tool' => $toolKey,
1964
                    'ctool_id' => (int) $cTool->getIid(),
1965
                ]);
1966
            }
1967
1968
            // Create/overwrite intro according to file policy
1969
            $intro = $introRepo->findOneBy(['courseTool' => $cTool]);
1970
1971
            if ($intro) {
1972
                if (FILE_SKIP === $this->file_option) {
1973
                    $this->dlog('restore_tool_intro: reuse existing (SKIP)', [
1974
                        'tool' => $toolKey,
1975
                        'intro_id' => (int) $intro->getIid(),
1976
                    ]);
1977
                } else {
1978
                    $intro->setIntroText($introHtml);
1979
                    $em->persist($intro);
1980
                    $em->flush();
1981
1982
                    $this->dlog('restore_tool_intro: intro overwritten', [
1983
                        'tool' => $toolKey,
1984
                        'intro_id' => (int) $intro->getIid(),
1985
                    ]);
1986
                }
1987
            } else {
1988
                $intro = (new CToolIntro())
1989
                    ->setCourseTool($cTool)
1990
                    ->setIntroText($introHtml)
1991
                    ->setParent($course);
1992
                $em->persist($intro);
1993
                $em->flush();
1994
1995
                $this->dlog('restore_tool_intro: intro created', [
1996
                    'tool' => $toolKey,
1997
                    'intro_id' => (int) $intro->getIid(),
1998
                ]);
1999
            }
2000
2001
            // Map destination id back into the bucket used
2002
            $this->course->resources[$bagKey][$rawId] ??= new \stdClass();
2003
            $this->course->resources[$bagKey][$rawId]->destination_id = (int) $intro->getIid();
2004
        }
2005
2006
        $this->dlog('restore_tool_intro: end');
2007
    }
2008
2009
    /**
2010
     * Restore calendar events.
2011
     */
2012
    public function restore_events(int $sessionId = 0): void
2013
    {
2014
        if (!$this->course->has_resources(RESOURCE_EVENT)) {
2015
            return;
2016
        }
2017
2018
        $resources = $this->course->resources ?? [];
2019
        $bag = $resources[RESOURCE_EVENT] ?? [];
2020
        $count = \is_array($bag) ? \count($bag) : 0;
2021
2022
        $this->dlog('restore_events: begin', ['count' => $count]);
2023
2024
        /** @var EntityManagerInterface $em */
2025
        $em = Database::getManager();
2026
        $course = api_get_course_entity($this->destination_course_id);
2027
        $session = api_get_session_entity($sessionId);
2028
        $group = api_get_group_entity();
2029
        $eventRepo = Container::getCalendarEventRepository();
2030
        $attachRepo = Container::getCalendarEventAttachmentRepository();
2031
2032
        // Dedupe by title inside same course/session
2033
        $findExistingByTitle = function (string $title) use ($eventRepo, $course, $session) {
2034
            $qb = $eventRepo->getResourcesByCourse($course, $session, null, null, true, true);
2035
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
2036
2037
            return $qb->getQuery()->getOneOrNullResult();
2038
        };
2039
2040
        $originPath = rtrim((string) ($this->course->backup_path ?? ''), '/').'/upload/calendar/';
2041
2042
        foreach ($bag as $oldId => $raw) {
2043
            // Already mapped?
2044
            $mapped = (int) ($raw->destination_id ?? 0);
2045
            if ($mapped > 0) {
2046
                $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]);
2047
2048
                continue;
2049
            }
2050
2051
            // Normalize + rewrite content
2052
            $title = trim((string) ($raw->title ?? ''));
2053
            if ('' === $title) {
2054
                $title = 'Event';
2055
            }
2056
2057
            $content = $this->rewriteHtmlForCourse((string) ($raw->content ?? ''), $sessionId, '[events.content]');
2058
2059
            // Dates
2060
            $allDay = (bool) ($raw->all_day ?? false);
2061
            $start = null;
2062
            $end = null;
2063
2064
            try {
2065
                $s = (string) ($raw->start_date ?? '');
2066
                if ('' !== $s) {
2067
                    $start = new DateTime($s);
2068
                }
2069
            } catch (Throwable) {
2070
            }
2071
2072
            try {
2073
                $e = (string) ($raw->end_date ?? '');
2074
                if ('' !== $e) {
2075
                    $end = new DateTime($e);
2076
                }
2077
            } catch (Throwable) {
2078
            }
2079
2080
            // Dedupe policy
2081
            $existing = $findExistingByTitle($title);
2082
            if ($existing) {
2083
                switch ($this->file_option) {
2084
                    case FILE_SKIP:
2085
                        $destId = (int) $existing->getIid();
2086
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2087
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
2088
                        $this->dlog('restore_events: reuse (SKIP)', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle()]);
2089
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
2090
2091
                        continue 2;
2092
2093
                    case FILE_OVERWRITE:
2094
                        $existing
2095
                            ->setTitle($title)
2096
                            ->setContent($content)
2097
                            ->setAllDay($allDay)
2098
                            ->setParent($course)
2099
                            ->addCourseLink($course, $session, $group)
2100
                        ;
2101
                        $existing->setStartDate($start);
2102
                        $existing->setEndDate($end);
2103
2104
                        $em->persist($existing);
2105
                        $em->flush();
2106
2107
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2108
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int) $existing->getIid();
2109
2110
                        $this->dlog('restore_events: overwrite', ['src_id' => (int) $oldId, 'dst_id' => (int) $existing->getIid(), 'title' => $title]);
2111
2112
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
2113
2114
                        continue 2;
2115
2116
                    case FILE_RENAME:
2117
                    default:
2118
                        $base = $title;
2119
                        $i = 1;
2120
                        $candidate = $base;
2121
                        while ($findExistingByTitle($candidate)) {
2122
                            $candidate = $base.' ('.(++$i).')';
2123
                        }
2124
                        $title = $candidate;
2125
2126
                        break;
2127
                }
2128
            }
2129
2130
            // Create new event
2131
            $entity = (new CCalendarEvent())
2132
                ->setTitle($title)
2133
                ->setContent($content)
2134
                ->setAllDay($allDay)
2135
                ->setParent($course)
2136
                ->addCourseLink($course, $session, $group)
2137
            ;
2138
2139
            $entity->setStartDate($start);
2140
            $entity->setEndDate($end);
2141
2142
            $em->persist($entity);
2143
            $em->flush();
2144
2145
            $destId = (int) $entity->getIid();
2146
            $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2147
            $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
2148
2149
            $this->dlog('restore_events: created', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title]);
2150
2151
            // Attachments
2152
            $this->restoreEventAttachments($raw, $entity, $originPath, $attachRepo, $em);
2153
        }
2154
2155
        $this->dlog('restore_events: end');
2156
    }
2157
2158
    /**
2159
     * Handle event attachments.
2160
     *
2161
     * @param mixed $attachRepo
2162
     */
2163
    private function restoreEventAttachments(
2164
        object $raw,
2165
        CCalendarEvent $entity,
2166
        string $originPath,
2167
        $attachRepo,
2168
        EntityManagerInterface $em
2169
    ): void {
2170
        // Helper to actually persist + move file
2171
        $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em): void {
2172
            if (!is_file($src) || !is_readable($src)) {
2173
                $this->dlog('restore_events: attachment source not readable', ['src' => $src]);
2174
2175
                return;
2176
            }
2177
2178
            // Avoid duplicate filenames on same event
2179
            foreach ($entity->getAttachments() as $att) {
2180
                if ($att->getFilename() === $filename) {
2181
                    $this->dlog('restore_events: attachment already exists, skipping', ['filename' => $filename]);
2182
2183
                    return;
2184
                }
2185
            }
2186
2187
            $attachment = (new CCalendarEventAttachment())
2188
                ->setFilename($filename)
2189
                ->setComment($comment ?? '')
2190
                ->setEvent($entity)
2191
                ->setParent($entity)
2192
                ->addCourseLink(
2193
                    api_get_course_entity($this->destination_course_id),
2194
                    api_get_session_entity(0),
2195
                    api_get_group_entity()
2196
                )
2197
            ;
2198
2199
            $em->persist($attachment);
2200
            $em->flush();
2201
2202
            if (method_exists($attachRepo, 'addFileFromLocalPath')) {
2203
                $attachRepo->addFileFromLocalPath($attachment, $src);
2204
            } else {
2205
                $dstDir = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/calendar/';
2206
                @mkdir($dstDir, 0775, true);
2207
                $newName = uniqid('calendar_', true);
2208
                @copy($src, $dstDir.$newName);
2209
            }
2210
2211
            $this->dlog('restore_events: attachment created', [
2212
                'event_id' => (int) $entity->getIid(),
2213
                'filename' => $filename,
2214
            ]);
2215
        };
2216
2217
        // modern backup fields on object
2218
        if (!empty($raw->attachment_path)) {
2219
            $src = rtrim($originPath, '/').'/'.$raw->attachment_path;
2220
            $filename = (string) ($raw->attachment_filename ?? basename($src));
2221
            $comment = (string) ($raw->attachment_comment ?? '');
2222
            $persistAttachmentFromFile($src, $filename, $comment);
2223
2224
            return;
2225
        }
2226
2227
        // legacy lookup from old course tables when ->orig present
2228
        if (!empty($this->course->orig)) {
2229
            $table = Database::get_course_table(TABLE_AGENDA_ATTACHMENT);
2230
            $sql = 'SELECT path, comment, filename
2231
                FROM '.$table.'
2232
                WHERE c_id = '.$this->destination_course_id.'
2233
                  AND agenda_id = '.(int) ($raw->source_id ?? 0);
2234
            $res = Database::query($sql);
2235
            while ($row = Database::fetch_object($res)) {
2236
                $src = rtrim($originPath, '/').'/'.$row->path;
2237
                $persistAttachmentFromFile($src, (string) $row->filename, (string) $row->comment);
2238
            }
2239
        }
2240
    }
2241
2242
    /**
2243
     * Restore course descriptions.
2244
     *
2245
     * @param mixed $session_id
2246
     */
2247
    public function restore_course_descriptions($session_id = 0): void
2248
    {
2249
        if (!$this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) {
2250
            return;
2251
        }
2252
2253
        $resources = $this->course->resources ?? [];
2254
        $count = \is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null)
2255
            ? \count($resources[RESOURCE_COURSEDESCRIPTION]) : 0;
2256
2257
        $this->dlog('restore_course_descriptions: begin', ['count' => $count]);
2258
2259
        $em = Database::getManager();
2260
        $repo = Container::getCourseDescriptionRepository();
2261
        $course = api_get_course_entity($this->destination_course_id);
2262
        $session = api_get_session_entity((int) $session_id);
2263
2264
        $findByTypeInCourse = function (int $type) use ($repo, $course, $session) {
2265
            if (method_exists($repo, 'findByTypeInCourse')) {
2266
                return $repo->findByTypeInCourse($type, $course, $session);
2267
            }
2268
            $qb = $repo->getResourcesByCourse($course, $session)
2269
                ->andWhere('resource.descriptionType = :t')
2270
                ->setParameter('t', $type)
2271
            ;
2272
2273
            return $qb->getQuery()->getResult();
2274
        };
2275
2276
        $findByTitleInCourse = function (string $title) use ($repo, $course, $session) {
2277
            $qb = $repo->getResourcesByCourse($course, $session)
2278
                ->andWhere('resource.title = :t')
2279
                ->setParameter('t', $title)
2280
                ->setMaxResults(1)
2281
            ;
2282
2283
            return $qb->getQuery()->getOneOrNullResult();
2284
        };
2285
2286
        foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $oldId => $cd) {
2287
            // Already mapped?
2288
            $mapped = (int) ($cd->destination_id ?? 0);
2289
            if ($mapped > 0) {
2290
                $this->dlog('restore_course_descriptions: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]);
2291
2292
                continue;
2293
            }
2294
2295
            // Normalize + rewrite
2296
            $rawTitle = (string) ($cd->title ?? '');
2297
            $rawContent = (string) ($cd->content ?? '');
2298
            $type = (int) ($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION);
2299
2300
            $title = '' !== trim($rawTitle) ? trim($rawTitle) : $rawTitle;
2301
            $content = $this->rewriteHtmlForCourse($rawContent, (int) $session_id, '[course_description.content]');
2302
2303
            // Policy by type
2304
            $existingByType = $findByTypeInCourse($type);
2305
            $existingOne = $existingByType[0] ?? null;
2306
2307
            if ($existingOne) {
2308
                switch ($this->file_option) {
2309
                    case FILE_SKIP:
2310
                        $destIid = (int) $existingOne->getIid();
2311
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2312
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2313
2314
                        $this->dlog('restore_course_descriptions: reuse (SKIP)', [
2315
                            'src_id' => (int) $oldId,
2316
                            'dst_id' => $destIid,
2317
                            'type' => $type,
2318
                            'title' => (string) $existingOne->getTitle(),
2319
                        ]);
2320
2321
                        continue 2;
2322
2323
                    case FILE_OVERWRITE:
2324
                        $existingOne
2325
                            ->setTitle('' !== $title ? $title : (string) $existingOne->getTitle())
2326
                            ->setContent($content)
2327
                            ->setDescriptionType($type)
2328
                            ->setProgress((int) ($cd->progress ?? 0))
2329
                        ;
2330
                        $existingOne->setParent($course)->addCourseLink($course, $session);
2331
2332
                        $em->persist($existingOne);
2333
                        $em->flush();
2334
2335
                        $destIid = (int) $existingOne->getIid();
2336
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2337
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2338
2339
                        $this->dlog('restore_course_descriptions: overwrite', [
2340
                            'src_id' => (int) $oldId,
2341
                            'dst_id' => $destIid,
2342
                            'type' => $type,
2343
                            'title' => (string) $existingOne->getTitle(),
2344
                        ]);
2345
2346
                        continue 2;
2347
2348
                    case FILE_RENAME:
2349
                    default:
2350
                        $base = '' !== $title ? $title : (string) ($cd->extra['title'] ?? 'Description');
2351
                        $i = 1;
2352
                        $candidate = $base;
2353
                        while ($findByTitleInCourse($candidate)) {
2354
                            $candidate = $base.' ('.(++$i).')';
2355
                        }
2356
                        $title = $candidate;
2357
2358
                        break;
2359
                }
2360
            }
2361
2362
            // Create new
2363
            $entity = (new CCourseDescription())
2364
                ->setTitle($title)
2365
                ->setContent($content)
2366
                ->setDescriptionType($type)
2367
                ->setProgress((int) ($cd->progress ?? 0))
2368
                ->setParent($course)
2369
                ->addCourseLink($course, $session)
2370
            ;
2371
2372
            $em->persist($entity);
2373
            $em->flush();
2374
2375
            $destIid = (int) $entity->getIid();
2376
            $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2377
            $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2378
2379
            $this->dlog('restore_course_descriptions: created', [
2380
                'src_id' => (int) $oldId,
2381
                'dst_id' => $destIid,
2382
                'type' => $type,
2383
                'title' => $title,
2384
            ]);
2385
        }
2386
2387
        $this->dlog('restore_course_descriptions: end');
2388
    }
2389
2390
    /**
2391
     * Restore announcements into the destination course.
2392
     *
2393
     * @param mixed $sessionId
2394
     */
2395
    public function restore_announcements($sessionId = 0): void
2396
    {
2397
        if (!$this->course->has_resources(RESOURCE_ANNOUNCEMENT)) {
2398
            return;
2399
        }
2400
2401
        $sessionId = (int) $sessionId;
2402
        $resources = $this->course->resources;
2403
2404
        $count = \is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null)
2405
            ? \count($resources[RESOURCE_ANNOUNCEMENT])
2406
            : 0;
2407
2408
        $this->dlog('restore_announcements: begin', ['count' => $count]);
2409
2410
        /** @var EntityManagerInterface $em */
2411
        $em = Database::getManager();
2412
        $course = api_get_course_entity($this->destination_course_id);
2413
        $session = api_get_session_entity($sessionId);
2414
        $group = api_get_group_entity();
2415
        $annRepo = Container::getAnnouncementRepository();
2416
        $attachRepo = Container::getAnnouncementAttachmentRepository();
2417
2418
        // Origin path for ZIP/imported attachments (kept as-is)
2419
        $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/';
2420
2421
        // Finder: existing announcement by title in this course/session
2422
        $findExistingByTitle = function (string $title) use ($annRepo, $course, $session) {
2423
            $qb = $annRepo->getResourcesByCourse($course, $session);
2424
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
2425
2426
            return $qb->getQuery()->getOneOrNullResult();
2427
        };
2428
2429
        foreach ($resources[RESOURCE_ANNOUNCEMENT] as $oldId => $a) {
2430
            // Already mapped?
2431
            $mapped = (int) ($a->destination_id ?? 0);
2432
            if ($mapped > 0) {
2433
                $this->dlog('restore_announcements: already mapped, skipping', [
2434
                    'src_id' => (int) $oldId, 'dst_id' => $mapped,
2435
                ]);
2436
2437
                continue;
2438
            }
2439
2440
            $title = trim((string) ($a->title ?? ''));
2441
            if ('' === $title) {
2442
                $title = 'Announcement';
2443
            }
2444
2445
            $contentHtml = (string) ($a->content ?? '');
2446
2447
            // Parse optional end date
2448
            $endDate = null;
2449
2450
            try {
2451
                $rawDate = (string) ($a->date ?? '');
2452
                if ('' !== $rawDate) {
2453
                    $endDate = new DateTime($rawDate);
2454
                }
2455
            } catch (Throwable $e) {
2456
                $endDate = null;
2457
            }
2458
2459
            $emailSent = (bool) ($a->email_sent ?? false);
2460
2461
            $existing = $findExistingByTitle($title);
2462
            if ($existing) {
2463
                switch ($this->file_option) {
2464
                    case FILE_SKIP:
2465
                        $destId = (int) $existing->getIid();
2466
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass();
2467
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2468
                        $this->dlog('restore_announcements: reuse (SKIP)', [
2469
                            'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle(),
2470
                        ]);
2471
                        // Still try to restore attachments on the reused entity
2472
                        $this->restoreAnnouncementAttachments($a, $existing, $originPath, $attachRepo, $em);
2473
2474
                        continue 2;
2475
2476
                    case FILE_OVERWRITE:
2477
                        // Continue to overwrite below
2478
                        break;
2479
2480
                    case FILE_RENAME:
2481
                    default:
2482
                        // Rename to avoid collision
2483
                        $base = $title;
2484
                        $i = 1;
2485
                        $candidate = $base;
2486
                        while ($findExistingByTitle($candidate)) {
2487
                            $i++;
2488
                            $candidate = $base.' ('.$i.')';
2489
                        }
2490
                        $title = $candidate;
2491
2492
                        break;
2493
                }
2494
            }
2495
2496
            // Rewrite HTML content using centralized helper (replaces manual mapping logic)
2497
            // Note: keeps attachments restoration logic unchanged.
2498
            $contentRewritten = $this->rewriteHtmlForCourse($contentHtml, $sessionId, '[announcements.content]');
2499
2500
            // Create or reuse entity
2501
            $entity = $existing ?: (new CAnnouncement());
2502
            $entity
2503
                ->setTitle($title)
2504
                ->setContent($contentRewritten) // content already rewritten
2505
                ->setParent($course)
2506
                ->addCourseLink($course, $session, $group)
2507
                ->setEmailSent($emailSent)
2508
            ;
2509
2510
            if ($endDate instanceof DateTimeInterface) {
2511
                $entity->setEndDate($endDate);
2512
            }
2513
2514
            $em->persist($entity);
2515
            $em->flush();
2516
2517
            $destId = (int) $entity->getIid();
2518
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass();
2519
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2520
2521
            $this->dlog($existing ? 'restore_announcements: overwrite' : 'restore_announcements: created', [
2522
                'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title,
2523
            ]);
2524
2525
            // Handle binary attachments from backup or source
2526
            $this->restoreAnnouncementAttachments($a, $entity, $originPath, $attachRepo, $em);
2527
        }
2528
2529
        $this->dlog('restore_announcements: end');
2530
    }
2531
2532
    /**
2533
     * Create/update CAnnouncementAttachment + ResourceFile for each attachment of an announcement.
2534
     * Sources:
2535
     *  - COPY mode (no ZIP):   from source announcement's ResourceFiles
2536
     *  - IMPORT mode (ZIP):    from /upload/announcements/* inside the package.
2537
     *
2538
     * Policies (by filename within the same announcement):
2539
     *  - FILE_SKIP:       skip if filename exists
2540
     *  - FILE_OVERWRITE:  reuse existing CAnnouncementAttachment and replace its ResourceFile
2541
     *  - FILE_RENAME:     create a new CAnnouncementAttachment with incremental suffix
2542
     */
2543
    private function restoreAnnouncementAttachments(
2544
        object $a,
2545
        CAnnouncement $entity,
2546
        string $originPath,
2547
        CAnnouncementAttachmentRepository $attachRepo,
2548
        EntityManagerInterface $em
2549
    ): void {
2550
        $copyMode = empty($this->course->backup_path);
2551
2552
        $findExistingByName = static function (CAnnouncement $ann, string $name) {
2553
            foreach ($ann->getAttachments() as $att) {
2554
                if ($att->getFilename() === $name) {
2555
                    return $att;
2556
                }
2557
            }
2558
2559
            return null;
2560
        };
2561
2562
        /**
2563
         * Decide target entity + final filename according to file policy.
2564
         * Returns [CAnnouncementAttachment|null $target, string|null $finalName, bool $isOverwrite].
2565
         */
2566
        $decideTarget = function (string $proposed, CAnnouncement $ann) use ($findExistingByName): array {
2567
            $policy = (int) $this->file_option;
2568
2569
            $existing = $findExistingByName($ann, $proposed);
2570
            if (!$existing) {
2571
                return [null, $proposed, false];
2572
            }
2573
2574
            if (\defined('FILE_SKIP') && FILE_SKIP === $policy) {
2575
                return [null, null, false];
2576
            }
2577
            if (\defined('FILE_OVERWRITE') && FILE_OVERWRITE === $policy) {
2578
                return [$existing, $proposed, true];
2579
            }
2580
2581
            $pi = pathinfo($proposed);
2582
            $base = $pi['filename'] ?? $proposed;
2583
            $ext = isset($pi['extension']) && '' !== $pi['extension'] ? ('.'.$pi['extension']) : '';
2584
            $i = 1;
2585
            do {
2586
                $candidate = $base.'_'.$i.$ext;
2587
                $i++;
2588
            } while ($findExistingByName($ann, $candidate));
2589
2590
            return [null, $candidate, false];
2591
        };
2592
2593
        $createAttachment = function (string $filename, string $comment, int $size) use ($entity, $em) {
2594
            $att = (new CAnnouncementAttachment())
2595
                ->setFilename($filename)
2596
                ->setPath(uniqid('announce_', true))
2597
                ->setComment($comment)
2598
                ->setSize($size)
2599
                ->setAnnouncement($entity)
2600
                ->setParent($entity)
2601
                ->addCourseLink(
2602
                    api_get_course_entity($this->destination_course_id),
2603
                    api_get_session_entity(0),
2604
                    api_get_group_entity()
2605
                )
2606
            ;
2607
            $em->persist($att);
2608
            $em->flush();
2609
2610
            return $att;
2611
        };
2612
2613
        /**
2614
         * Search helper: try a list of absolute paths, then recursive search in a base dir by filename.
2615
         * Returns ['src'=>abs, 'filename'=>..., 'comment'=>..., 'size'=>int] or null.
2616
         */
2617
        $resolveSourceFile = function (array $candidates, array $fallbackDirs, string $filename) {
2618
            // 1) direct candidates (absolute paths)
2619
            foreach ($candidates as $meta) {
2620
                if (!empty($meta['src']) && is_file($meta['src']) && is_readable($meta['src'])) {
2621
                    $meta['filename'] = $meta['filename'] ?: basename($meta['src']);
2622
                    $meta['size'] = (int) ($meta['size'] ?: (filesize($meta['src']) ?: 0));
2623
2624
                    return $meta;
2625
                }
2626
            }
2627
2628
            // 2) recursive search by filename inside fallback dirs
2629
            $filename = trim($filename);
2630
            if ('' !== $filename) {
2631
                foreach ($fallbackDirs as $base) {
2632
                    $base = rtrim($base, '/').'/';
2633
                    if (!is_dir($base)) {
2634
                        continue;
2635
                    }
2636
                    $it = new RecursiveIteratorIterator(
2637
                        new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
2638
                        RecursiveIteratorIterator::SELF_FIRST
2639
                    );
2640
                    foreach ($it as $f) {
2641
                        if ($f->isFile() && $f->getFilename() === $filename) {
2642
                            return [
2643
                                'src' => $f->getRealPath(),
2644
                                'filename' => $filename,
2645
                                'comment' => (string) ($candidates[0]['comment'] ?? ''),
2646
                                'size' => (int) ($candidates[0]['size'] ?? (filesize($f->getRealPath()) ?: 0)),
2647
                            ];
2648
                        }
2649
                    }
2650
                }
2651
            }
2652
2653
            return null;
2654
        };
2655
2656
        $storeBinaryFromPath = function (
2657
            CAnnouncementAttachment $target,
2658
            string $absPath
2659
        ) use ($attachRepo): void {
2660
            // This exists in your ResourceRepository
2661
            $attachRepo->addFileFromPath($target, $target->getFilename(), $absPath, true);
2662
        };
2663
2664
        // ---------------------- COPY MODE (course->course) ----------------------
2665
        if ($copyMode) {
2666
            $srcAttachmentIds = [];
2667
2668
            if (!empty($a->attachment_source_id)) {
2669
                $srcAttachmentIds[] = (int) $a->attachment_source_id;
2670
            }
2671
            if (!empty($a->attachment_source_ids) && \is_array($a->attachment_source_ids)) {
2672
                foreach ($a->attachment_source_ids as $sid) {
2673
                    $sid = (int) $sid;
2674
                    if ($sid > 0) {
2675
                        $srcAttachmentIds[] = $sid;
2676
                    }
2677
                }
2678
            }
2679
            if (empty($srcAttachmentIds) && !empty($a->source_id)) {
2680
                $srcAnn = Container::getAnnouncementRepository()->find((int) $a->source_id);
2681
                if ($srcAnn) {
2682
                    $srcAtts = Container::getAnnouncementAttachmentRepository()->findBy(['announcement' => $srcAnn]);
2683
                    foreach ($srcAtts as $sa) {
2684
                        $srcAttachmentIds[] = (int) $sa->getIid();
2685
                    }
2686
                }
2687
            }
2688
2689
            if (empty($srcAttachmentIds)) {
2690
                $this->dlog('restore_announcements: no source attachments found in COPY mode', [
2691
                    'dst_announcement_id' => (int) $entity->getIid(),
2692
                ]);
2693
2694
                return;
2695
            }
2696
2697
            $attRepo = Container::getAnnouncementAttachmentRepository();
2698
2699
            foreach (array_unique($srcAttachmentIds) as $sid) {
2700
                /** @var CAnnouncementAttachment|null $srcAtt */
2701
                $srcAtt = $attRepo->find((int) $sid);
2702
                if (!$srcAtt) {
2703
                    continue;
2704
                }
2705
2706
                $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt);
2707
                if (!$abs) {
2708
                    $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]);
2709
2710
                    continue;
2711
                }
2712
2713
                $proposed = $srcAtt->getFilename() ?: basename($abs);
2714
                [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity);
2715
2716
                if (null === $finalName) {
2717
                    $this->dlog('restore_announcements: skipped due to FILE_SKIP policy', [
2718
                        'src_att_id' => $sid,
2719
                        'filename' => $proposed,
2720
                    ]);
2721
2722
                    continue;
2723
                }
2724
2725
                if (null === $targetAttachment) {
2726
                    $targetAttachment = $createAttachment(
2727
                        $finalName,
2728
                        (string) $srcAtt->getComment(),
2729
                        (int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0))
2730
                    );
2731
                } else {
2732
                    $targetAttachment
2733
                        ->setComment((string) $srcAtt->getComment())
2734
                        ->setSize((int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0)))
2735
                    ;
2736
                    $em->persist($targetAttachment);
2737
                    $em->flush();
2738
                }
2739
2740
                $storeBinaryFromPath($targetAttachment, $abs);
2741
2742
                $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'copied').' from ResourceFile', [
2743
                    'dst_announcement_id' => (int) $entity->getIid(),
2744
                    'filename' => $targetAttachment->getFilename(),
2745
                    'size' => $targetAttachment->getSize(),
2746
                ]);
2747
            }
2748
2749
            return;
2750
        }
2751
2752
        $candidates = [];
2753
2754
        // Primary (from serialized record)
2755
        if (!empty($a->attachment_path)) {
2756
            $maybe = rtrim($originPath, '/').'/'.$a->attachment_path;
2757
            $filename = (string) ($a->attachment_filename ?? '');
2758
            if (is_file($maybe)) {
2759
                $candidates[] = [
2760
                    'src' => $maybe,
2761
                    'filename' => '' !== $filename ? $filename : basename($maybe),
2762
                    'comment' => (string) ($a->attachment_comment ?? ''),
2763
                    'size' => (int) ($a->attachment_size ?? (filesize($maybe) ?: 0)),
2764
                ];
2765
            } elseif (is_dir($maybe)) {
2766
                $try = '' !== $filename ? $maybe.'/'.$filename : '';
2767
                if ('' !== $try && is_file($try)) {
2768
                    $candidates[] = [
2769
                        'src' => $try,
2770
                        'filename' => $filename,
2771
                        'comment' => (string) ($a->attachment_comment ?? ''),
2772
                        'size' => (int) ($a->attachment_size ?? (filesize($try) ?: 0)),
2773
                    ];
2774
                } else {
2775
                    $files = [];
2776
                    foreach (new FilesystemIterator($maybe, FilesystemIterator::SKIP_DOTS) as $f) {
2777
                        if ($f->isFile()) {
2778
                            $files[] = $f->getRealPath();
2779
                        }
2780
                    }
2781
                    if (1 === \count($files)) {
2782
                        $one = $files[0];
2783
                        $candidates[] = [
2784
                            'src' => $one,
2785
                            'filename' => '' !== $filename ? $filename : basename($one),
2786
                            'comment' => (string) ($a->attachment_comment ?? ''),
2787
                            'size' => (int) ($a->attachment_size ?? (filesize($one) ?: 0)),
2788
                        ];
2789
                    }
2790
                }
2791
            }
2792
        }
2793
2794
        // Fallback DB snapshot
2795
        if (!empty($this->course->orig)) {
2796
            $table = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT);
2797
            $sql = 'SELECT path, comment, size, filename
2798
                FROM '.$table.'
2799
                WHERE c_id = '.$this->destination_course_id.'
2800
                  AND announcement_id = '.(int) ($a->source_id ?? 0);
2801
            $res = Database::query($sql);
2802
            while ($row = Database::fetch_object($res)) {
2803
                $base = rtrim($originPath, '/').'/'.$row->path;
2804
                $abs = null;
2805
2806
                if (is_file($base)) {
2807
                    $abs = $base;
2808
                } elseif (is_dir($base)) {
2809
                    $try = $base.'/'.$row->filename;
2810
                    if (is_file($try)) {
2811
                        $abs = $try;
2812
                    } else {
2813
                        $files = [];
2814
                        foreach (new FilesystemIterator($base, FilesystemIterator::SKIP_DOTS) as $f) {
2815
                            if ($f->isFile()) {
2816
                                $files[] = $f->getRealPath();
2817
                            }
2818
                        }
2819
                        if (1 === \count($files)) {
2820
                            $abs = $files[0];
2821
                        }
2822
                    }
2823
                }
2824
2825
                if ($abs && is_readable($abs)) {
2826
                    $candidates[] = [
2827
                        'src' => $abs,
2828
                        'filename' => (string) $row->filename,
2829
                        'comment' => (string) $row->comment,
2830
                        'size' => (int) ($row->size ?: (filesize($abs) ?: 0)),
2831
                    ];
2832
                }
2833
            }
2834
        }
2835
2836
        $fallbackDirs = [
2837
            rtrim($this->course->backup_path ?? '', '/').'/upload/announcements',
2838
            rtrim($this->course->backup_path ?? '', '/').'/upload',
2839
        ];
2840
2841
        $preferredFilename = (string) ($a->attachment_filename ?? '');
2842
        if ('' === $preferredFilename && !empty($candidates)) {
2843
            $preferredFilename = (string) ($candidates[0]['filename'] ?? '');
2844
        }
2845
2846
        $resolved = $resolveSourceFile($candidates, $fallbackDirs, $preferredFilename);
2847
        if (!$resolved) {
2848
            $this->dlog('restore_announcements: no ZIP attachments could be resolved', [
2849
                'dst_announcement_id' => (int) $entity->getIid(),
2850
                'originPath' => $originPath,
2851
                'hint' => 'Check upload/announcements and upload paths inside the package',
2852
            ]);
2853
2854
            return;
2855
        }
2856
2857
        $proposed = $resolved['filename'] ?: basename($resolved['src']);
2858
        [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity);
2859
2860
        if (null === $finalName) {
2861
            $this->dlog('restore_announcements: skipped due to FILE_SKIP policy (ZIP)', [
2862
                'filename' => $proposed,
2863
            ]);
2864
2865
            return;
2866
        }
2867
2868
        if (null === $targetAttachment) {
2869
            $targetAttachment = $createAttachment(
2870
                $finalName,
2871
                (string) $resolved['comment'],
2872
                (int) $resolved['size']
2873
            );
2874
        } else {
2875
            $targetAttachment
2876
                ->setComment((string) $resolved['comment'])
2877
                ->setSize((int) $resolved['size'])
2878
            ;
2879
            $em->persist($targetAttachment);
2880
            $em->flush();
2881
        }
2882
2883
        $storeBinaryFromPath($targetAttachment, $resolved['src']);
2884
2885
        $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'stored (ZIP)'), [
2886
            'announcement_id' => (int) $entity->getIid(),
2887
            'filename' => $targetAttachment->getFilename(),
2888
            'size' => $targetAttachment->getSize(),
2889
            'src' => $resolved['src'],
2890
        ]);
2891
    }
2892
2893
    /**
2894
     * Restore quizzes and their questions into the destination course.
2895
     *
2896
     * @param mixed $session_id
2897
     * @param mixed $respect_base_content
2898
     */
2899
    public function restore_quizzes($session_id = 0, $respect_base_content = false): void
2900
    {
2901
        if (!$this->course->has_resources(RESOURCE_QUIZ)) {
2902
            error_log('RESTORE_QUIZ: No quiz resources in backup.');
2903
2904
            return;
2905
        }
2906
2907
        $em = Database::getManager();
2908
        $resources = $this->course->resources;
2909
        $courseEntity = api_get_course_entity($this->destination_course_id);
2910
        $sessionEntity = !empty($session_id) ? api_get_session_entity((int) $session_id) : api_get_session_entity();
2911
2912
        // Safe wrapper around rewriteHtmlForCourse
2913
        $rw = function (?string $html, string $dbgTag = 'QZ') use ($session_id) {
2914
            if (null === $html || false === $html || '' === $html) {
2915
                return '';
2916
            }
2917
2918
            try {
2919
                return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag);
2920
            } catch (Throwable $e) {
2921
                error_log('RESTORE_QUIZ: rewriteHtmlForCourse failed: '.$e->getMessage());
2922
2923
                return (string) $html;
2924
            }
2925
        };
2926
2927
        // Backward compat alias for legacy key
2928
        if (empty($this->course->resources[RESOURCE_QUIZQUESTION])
2929
            && !empty($this->course->resources['Exercise_Question'])) {
2930
            $this->course->resources[RESOURCE_QUIZQUESTION] = $this->course->resources['Exercise_Question'];
2931
            $resources = $this->course->resources;
2932
            error_log('RESTORE_QUIZ: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
2933
        }
2934
2935
        foreach ($resources[RESOURCE_QUIZ] as $id => $quizWrap) {
2936
            if ((int) ($this->course->resources[RESOURCE_QUIZ][$id]->destination_id ?? 0) > 0) {
2937
                $this->dlog('RESTORE_QUIZ: already mapped, skipping', ['src_quiz_id' => (int) $id]);
2938
                continue;
2939
            }
2940
            $quiz = isset($quizWrap->obj) ? $quizWrap->obj : $quizWrap;
2941
2942
            // Rewrite HTML-bearing fields
2943
            $description = $rw($quiz->description ?? '', 'QZ.desc');
2944
            $textFinished = $rw($quiz->text_when_finished ?? '', 'QZ.done.ok');
2945
            $textFinishedKo = $rw($quiz->text_when_finished_failure ?? '', 'QZ.done.ko');
2946
2947
            // Normalize dates
2948
            $quiz->start_time = (property_exists($quiz, 'start_time') && '0000-00-00 00:00:00' !== $quiz->start_time)
2949
                ? $quiz->start_time
2950
                : null;
2951
            $quiz->end_time = (property_exists($quiz, 'end_time') && '0000-00-00 00:00:00' !== $quiz->end_time)
2952
                ? $quiz->end_time
2953
                : null;
2954
2955
            global $_custom;
2956
            if (!empty($_custom['exercises_clean_dates_when_restoring'])) {
2957
                $quiz->start_time = null;
2958
                $quiz->end_time = null;
2959
            }
2960
2961
            if (-1 === (int) $id) {
2962
                $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = -1;
2963
                error_log('RESTORE_QUIZ: Skipping virtual quiz (id=-1).');
2964
2965
                continue;
2966
            }
2967
2968
            $entity = (new CQuiz())
2969
                ->setParent($courseEntity)
2970
                ->addCourseLink(
2971
                    $courseEntity,
2972
                    $respect_base_content ? $sessionEntity : (!empty($session_id) ? $sessionEntity : api_get_session_entity()),
2973
                    api_get_group_entity()
2974
                )
2975
                ->setTitle((string) $quiz->title)
2976
                ->setDescription($description)
2977
                ->setType(isset($quiz->quiz_type) ? (int) $quiz->quiz_type : (int) $quiz->type)
2978
                ->setRandom((int) $quiz->random)
2979
                ->setRandomAnswers((bool) $quiz->random_answers)
2980
                ->setResultsDisabled((int) $quiz->results_disabled)
2981
                ->setMaxAttempt((int) $quiz->max_attempt)
2982
                ->setFeedbackType((int) $quiz->feedback_type)
2983
                ->setExpiredTime((int) $quiz->expired_time)
2984
                ->setReviewAnswers((int) $quiz->review_answers)
2985
                ->setRandomByCategory((int) $quiz->random_by_category)
2986
                ->setTextWhenFinished($textFinished)
2987
                ->setTextWhenFinishedFailure($textFinishedKo)
2988
                ->setDisplayCategoryName((int) ($quiz->display_category_name ?? 0))
2989
                ->setSaveCorrectAnswers(isset($quiz->save_correct_answers) ? (int) $quiz->save_correct_answers : 0)
2990
                ->setPropagateNeg((int) $quiz->propagate_neg)
2991
                ->setHideQuestionTitle((bool) ($quiz->hide_question_title ?? false))
2992
                ->setHideQuestionNumber((int) ($quiz->hide_question_number ?? 0))
2993
                ->setStartTime(!empty($quiz->start_time) ? new DateTime((string) $quiz->start_time) : null)
2994
                ->setEndTime(!empty($quiz->end_time) ? new DateTime((string) $quiz->end_time) : null)
2995
            ;
2996
2997
            if (isset($quiz->access_condition) && '' !== $quiz->access_condition) {
2998
                $entity->setAccessCondition((string) $quiz->access_condition);
2999
            }
3000
            if (isset($quiz->pass_percentage) && '' !== $quiz->pass_percentage && null !== $quiz->pass_percentage) {
3001
                $entity->setPassPercentage((int) $quiz->pass_percentage);
3002
            }
3003
            if (isset($quiz->question_selection_type) && '' !== $quiz->question_selection_type && null !== $quiz->question_selection_type) {
3004
                $entity->setQuestionSelectionType((int) $quiz->question_selection_type);
3005
            }
3006
            if ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')) {
3007
                $entity->setNotifications((string) ($quiz->notifications ?? ''));
3008
            }
3009
3010
            $em->persist($entity);
3011
            $em->flush();
3012
3013
            $newQuizId = (int) $entity->getIid();
3014
            $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $newQuizId;
3015
3016
            $qCount = isset($quiz->question_ids) ? \count((array) $quiz->question_ids) : 0;
3017
            error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string) $quiz->title.'" with '.$qCount.' question ids.');
3018
3019
            $order = 0;
3020
            if (!empty($quiz->question_ids)) {
3021
                foreach ($quiz->question_ids as $index => $question_id) {
3022
                    $qid = $this->restore_quiz_question($question_id, (int) $session_id);
3023
                    if (!$qid) {
3024
                        error_log('RESTORE_QUIZ: restore_quiz_question returned 0 for src_question_id='.$question_id);
3025
3026
                        continue;
3027
                    }
3028
3029
                    $question_order = !empty($quiz->question_orders[$index])
3030
                        ? (int) $quiz->question_orders[$index]
3031
                        : $order;
3032
3033
                    $order++;
3034
3035
                    $questionEntity = $em->getRepository(CQuizQuestion::class)->find($qid);
3036
                    if (!$questionEntity) {
3037
                        error_log('RESTORE_QUIZ: Question entity not found after insert. qid='.$qid);
3038
3039
                        continue;
3040
                    }
3041
3042
                    $rel = (new CQuizRelQuestion())
3043
                        ->setQuiz($entity)
3044
                        ->setQuestion($questionEntity)
3045
                        ->setQuestionOrder($question_order)
3046
                    ;
3047
3048
                    $em->persist($rel);
3049
                    $em->flush();
3050
                }
3051
            } else {
3052
                error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string) $quiz->title.'").');
3053
            }
3054
        }
3055
    }
3056
3057
    /**
3058
     * Restore quiz-questions. Returns new question IID.
3059
     *
3060
     * @param mixed $id
3061
     */
3062
    public function restore_quiz_question($id, int $session_id = 0)
3063
    {
3064
        $em = Database::getManager();
3065
        $resources = $this->course->resources;
3066
3067
        if (empty($resources[RESOURCE_QUIZQUESTION]) && !empty($resources['Exercise_Question'])) {
3068
            $resources[RESOURCE_QUIZQUESTION] = $this->course->resources[RESOURCE_QUIZQUESTION]
3069
                = $this->course->resources['Exercise_Question'];
3070
            error_log('RESTORE_QUESTION: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
3071
        }
3072
3073
        /** @var object|null $question */
3074
        $question = $resources[RESOURCE_QUIZQUESTION][$id] ?? null;
3075
        if (!\is_object($question)) {
3076
            error_log('RESTORE_QUESTION: Question not found in resources. src_id='.$id);
3077
3078
            return 0;
3079
        }
3080
        if (method_exists($question, 'is_restored') && $question->is_restored()) {
3081
            return (int) $question->destination_id;
3082
        }
3083
3084
        $courseEntity = api_get_course_entity($this->destination_course_id);
3085
3086
        // Safe wrapper around rewriteHtmlForCourse
3087
        $rw = function (?string $html, string $dbgTag = 'QZ.Q') use ($session_id) {
3088
            if (null === $html || false === $html || '' === $html) {
3089
                return '';
3090
            }
3091
3092
            try {
3093
                return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag);
3094
            } catch (Throwable $e) {
3095
                error_log('RESTORE_QUESTION: rewriteHtmlForCourse failed: '.$e->getMessage());
3096
3097
                return (string) $html;
3098
            }
3099
        };
3100
3101
        // Rewrite statement & description
3102
        $question->description = $rw($question->description ?? '', 'QZ.Q.desc');
3103
        $question->question = $rw($question->question ?? '', 'QZ.Q.text');
3104
3105
        // Picture mapping (kept as in your code)
3106
        $imageNewId = '';
3107
        if (!empty($question->picture)) {
3108
            if (isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture])) {
3109
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]['destination_id'];
3110
            } elseif (isset($resources[RESOURCE_DOCUMENT][$question->picture])) {
3111
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT][$question->picture]->destination_id;
3112
            }
3113
        }
3114
3115
        $qType = (int) ($question->quiz_type ?? $question->type);
3116
        $entity = (new CQuizQuestion())
3117
            ->setParent($courseEntity)
3118
            ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity())
3119
            ->setQuestion($question->question)
3120
            ->setDescription($question->description)
3121
            ->setPonderation((float) ($question->ponderation ?? 0))
3122
            ->setPosition((int) ($question->position ?? 1))
3123
            ->setType($qType)
3124
            ->setPicture($imageNewId)
3125
            ->setLevel((int) ($question->level ?? 1))
3126
            ->setExtra((string) ($question->extra ?? ''))
3127
        ;
3128
3129
        $em->persist($entity);
3130
        $em->flush();
3131
3132
        $new_id = (int) $entity->getIid();
3133
        if (!$new_id) {
3134
            error_log('RESTORE_QUESTION: Failed to obtain new question iid for src_id='.$id);
3135
3136
            return 0;
3137
        }
3138
3139
        $answers = (array) ($question->answers ?? []);
3140
        error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.\count($answers));
3141
3142
        $isMatchingFamily = \in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true);
3143
        $correctMapSrcToDst = []; // dstAnsId => srcCorrectRef
3144
        $allSrcAnswersById = []; // srcAnsId => text
3145
        $dstAnswersByIdText = []; // dstAnsId => text
3146
3147
        if ($isMatchingFamily) {
3148
            foreach ($answers as $a) {
3149
                $allSrcAnswersById[$a['id']] = $rw($a['answer'] ?? '', 'QZ.Q.ans.all');
3150
            }
3151
        }
3152
3153
        foreach ($answers as $a) {
3154
            $ansText = $rw($a['answer'] ?? '', 'QZ.Q.ans');
3155
            $comment = $rw($a['comment'] ?? '', 'QZ.Q.ans.cmt');
3156
3157
            $ans = (new CQuizAnswer())
3158
                ->setQuestion($entity)
3159
                ->setAnswer((string) $ansText)
3160
                ->setComment((string) $comment)
3161
                ->setPonderation((float) ($a['ponderation'] ?? 0))
3162
                ->setPosition((int) ($a['position'] ?? 0))
3163
                ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string) $a['hotspot_coordinates'] : null)
3164
                ->setHotspotType(isset($a['hotspot_type']) ? (string) $a['hotspot_type'] : null)
3165
            ;
3166
3167
            if (isset($a['correct']) && '' !== $a['correct'] && null !== $a['correct']) {
3168
                $ans->setCorrect((int) $a['correct']);
3169
            }
3170
3171
            $em->persist($ans);
3172
            $em->flush();
3173
3174
            if ($isMatchingFamily) {
3175
                $correctMapSrcToDst[(int) $ans->getIid()] = $a['correct'] ?? null;
3176
                $dstAnswersByIdText[(int) $ans->getIid()] = $ansText;
3177
            }
3178
        }
3179
3180
        if ($isMatchingFamily && $correctMapSrcToDst) {
3181
            foreach ($entity->getAnswers() as $dstAns) {
3182
                $dstAid = (int) $dstAns->getIid();
3183
                $srcRef = $correctMapSrcToDst[$dstAid] ?? null;
3184
                if (null === $srcRef) {
3185
                    continue;
3186
                }
3187
3188
                if (isset($allSrcAnswersById[$srcRef])) {
3189
                    $needle = $allSrcAnswersById[$srcRef];
3190
                    $newDst = null;
3191
                    foreach ($dstAnswersByIdText as $candId => $txt) {
3192
                        if ($txt === $needle) {
3193
                            $newDst = $candId;
3194
3195
                            break;
3196
                        }
3197
                    }
3198
                    if (null !== $newDst) {
3199
                        $dstAns->setCorrect((int) $newDst);
3200
                        $em->persist($dstAns);
3201
                    }
3202
                }
3203
            }
3204
            $em->flush();
3205
        }
3206
3207
        if (\defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) {
3208
            $newOptByOld = [];
3209
            if (isset($question->question_options) && is_iterable($question->question_options)) {
3210
                foreach ($question->question_options as $optWrap) {
3211
                    $opt = $optWrap->obj ?? $optWrap;
3212
                    $optTitle = $rw($opt->name ?? '', 'QZ.Q.opt'); // rewrite option title too
3213
                    $optEntity = (new CQuizQuestionOption())
3214
                        ->setQuestion($entity)
3215
                        ->setTitle((string) $optTitle)
3216
                        ->setPosition((int) $opt->position)
3217
                    ;
3218
                    $em->persist($optEntity);
3219
                    $em->flush();
3220
                    $newOptByOld[$opt->id] = (int) $optEntity->getIid();
3221
                }
3222
                foreach ($entity->getAnswers() as $dstAns) {
3223
                    $corr = $dstAns->getCorrect();
3224
                    if (null !== $corr && isset($newOptByOld[$corr])) {
3225
                        $dstAns->setCorrect((int) $newOptByOld[$corr]);
3226
                        $em->persist($dstAns);
3227
                    }
3228
                }
3229
                $em->flush();
3230
            }
3231
        }
3232
3233
        $this->course->resources[RESOURCE_QUIZQUESTION][$id]->destination_id = $new_id;
3234
3235
        return $new_id;
3236
    }
3237
3238
    /**
3239
     * Restore surveys from backup into the destination course.
3240
     *
3241
     * @param mixed $sessionId
3242
     */
3243
    public function restore_surveys($sessionId = 0): void
3244
    {
3245
        if (!$this->course->has_resources(RESOURCE_SURVEY)) {
3246
            $this->debug && error_log('COURSE_DEBUG: restore_surveys: no survey resources in backup.');
3247
3248
            return;
3249
        }
3250
3251
        $em = Database::getManager();
3252
        $surveyRepo = Container::getSurveyRepository();
3253
3254
        /** @var CourseEntity $courseEntity */
3255
        $courseEntity = api_get_course_entity($this->destination_course_id);
3256
3257
        /** @var SessionEntity|null $sessionEntity */
3258
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
3259
3260
        $sid = (int) ($sessionEntity?->getId() ?? 0);
3261
3262
        $rewrite = function (?string $html, string $tag = '') use ($sid) {
3263
            if (null === $html || '' === $html) {
3264
                return '';
3265
            }
3266
3267
            return $this->rewriteHtmlForCourse((string) $html, $sid, $tag);
3268
        };
3269
3270
        $resources = $this->course->resources;
3271
3272
        foreach ($resources[RESOURCE_SURVEY] as $legacySurveyId => $surveyObj) {
3273
            try {
3274
                $code = (string) ($surveyObj->code ?? '');
3275
                $lang = (string) ($surveyObj->lang ?? '');
3276
3277
                $title = $rewrite($surveyObj->title ?? '', ':survey.title');
3278
                $subtitle = $rewrite($surveyObj->subtitle ?? '', ':survey.subtitle');
3279
                $intro = $rewrite($surveyObj->intro ?? '', ':survey.intro');
3280
                $surveyThanks = $rewrite($surveyObj->surveythanks ?? '', ':survey.thanks');
3281
3282
                $onePerPage = !empty($surveyObj->one_question_per_page);
3283
                $shuffle = isset($surveyObj->shuffle) ? (bool) $surveyObj->shuffle : (!empty($surveyObj->suffle));
3284
                $anonymous = (string) ((int) ($surveyObj->anonymous ?? 0));
3285
3286
                try {
3287
                    $creationDate = !empty($surveyObj->creation_date) ? new DateTime((string) $surveyObj->creation_date) : new DateTime();
3288
                } catch (Throwable) {
3289
                    $creationDate = new DateTime();
3290
                }
3291
3292
                try {
3293
                    $availFrom = !empty($surveyObj->avail_from) ? new DateTime((string) $surveyObj->avail_from) : null;
3294
                } catch (Throwable) {
3295
                    $availFrom = null;
3296
                }
3297
3298
                try {
3299
                    $availTill = !empty($surveyObj->avail_till) ? new DateTime((string) $surveyObj->avail_till) : null;
3300
                } catch (Throwable) {
3301
                    $availTill = null;
3302
                }
3303
3304
                $visibleResults = isset($surveyObj->visible_results) ? (int) $surveyObj->visible_results : null;
3305
                $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool) $surveyObj->display_question_number : true;
3306
3307
                $existing = null;
3308
3309
                try {
3310
                    if (method_exists($surveyRepo, 'findOneByCodeAndLangInCourse')) {
3311
                        $existing = $surveyRepo->findOneByCodeAndLangInCourse($courseEntity, $code, $lang);
3312
                    } else {
3313
                        $existing = $surveyRepo->findOneBy(['code' => $code, 'lang' => $lang]);
3314
                    }
3315
                } catch (Throwable $e) {
3316
                    $this->debug && error_log('COURSE_DEBUG: restore_surveys: duplicate check skipped: '.$e->getMessage());
3317
                }
3318
3319
                if ($existing instanceof CSurvey) {
3320
                    switch ($this->file_option) {
3321
                        case FILE_SKIP:
3322
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid();
3323
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: survey exists code='$code' (skip).");
3324
3325
                            continue 2;
3326
3327
                        case FILE_RENAME:
3328
                            $base = '' !== $code ? $code.'_' : 'survey_';
3329
                            $i = 1;
3330
                            $try = $base.$i;
3331
                            while (!$this->is_survey_code_available($try)) {
3332
                                $try = $base.(++$i);
3333
                            }
3334
                            $code = $try;
3335
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: renaming to '$code'.");
3336
3337
                            break;
3338
3339
                        case FILE_OVERWRITE:
3340
                            SurveyManager::deleteSurvey($existing);
3341
                            $em->flush();
3342
                            $this->debug && error_log('COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite).');
3343
3344
                            break;
3345
3346
                        default:
3347
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid();
3348
3349
                            continue 2;
3350
                    }
3351
                }
3352
3353
                // --- Create survey ---
3354
                $newSurvey = new CSurvey();
3355
                $newSurvey
3356
                    ->setCode($code)
3357
                    ->setTitle($title)
3358
                    ->setSubtitle($subtitle)
3359
                    ->setLang($lang)
3360
                    ->setAvailFrom($availFrom)
3361
                    ->setAvailTill($availTill)
3362
                    ->setIsShared((string) ($surveyObj->is_shared ?? '0'))
3363
                    ->setTemplate((string) ($surveyObj->template ?? 'template'))
3364
                    ->setIntro($intro)
3365
                    ->setSurveythanks($surveyThanks)
3366
                    ->setCreationDate($creationDate)
3367
                    ->setInvited(0)
3368
                    ->setAnswered(0)
3369
                    ->setInviteMail((string) ($surveyObj->invite_mail ?? ''))
3370
                    ->setReminderMail((string) ($surveyObj->reminder_mail ?? ''))
3371
                    ->setOneQuestionPerPage($onePerPage)
3372
                    ->setShuffle($shuffle)
3373
                    ->setAnonymous($anonymous)
3374
                    ->setDisplayQuestionNumber($displayQuestionNumber)
3375
                ;
3376
3377
                if (method_exists($newSurvey, 'setParent')) {
3378
                    $newSurvey->setParent($courseEntity);
3379
                }
3380
                if (method_exists($newSurvey, 'addCourseLink')) {
3381
                    $newSurvey->addCourseLink($courseEntity, $sessionEntity);
3382
                }
3383
3384
                if (method_exists($surveyRepo, 'create')) {
3385
                    $surveyRepo->create($newSurvey);
3386
                } else {
3387
                    $em->persist($newSurvey);
3388
                    $em->flush();
3389
                }
3390
3391
                if (null !== $visibleResults && method_exists($newSurvey, 'setVisibleResults')) {
3392
                    $newSurvey->setVisibleResults($visibleResults);
3393
                    $em->flush();
3394
                }
3395
3396
                $newId = (int) $newSurvey->getIid();
3397
                $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = $newId;
3398
3399
                // Restore questions
3400
                $questionIds = \is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : [];
3401
                if (empty($questionIds) && !empty($resources[RESOURCE_SURVEYQUESTION])) {
3402
                    foreach ($resources[RESOURCE_SURVEYQUESTION] as $qid => $qWrap) {
3403
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
3404
                        if ((int) ($q->survey_id ?? 0) === (int) $legacySurveyId) {
3405
                            $questionIds[] = (int) $qid;
3406
                        }
3407
                    }
3408
                }
3409
3410
                foreach ($questionIds as $legacyQid) {
3411
                    $this->restore_survey_question((int) $legacyQid, $newId, $sid);
3412
                }
3413
3414
                $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".\count($questionIds));
3415
            } catch (Throwable $e) {
3416
                error_log('COURSE_DEBUG: restore_surveys: failed: '.$e->getMessage());
3417
            }
3418
        }
3419
    }
3420
3421
    /**
3422
     * Restore survey-questions. $survey_id is the NEW iid.
3423
     *
3424
     * @param mixed $id
3425
     * @param mixed $survey_id
3426
     */
3427
    public function restore_survey_question($id, $survey_id, ?int $sid = null)
3428
    {
3429
        $resources = $this->course->resources;
3430
        $qWrap = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null;
3431
3432
        if (!$qWrap || !\is_object($qWrap)) {
3433
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: legacy question $id not found.");
3434
3435
            return 0;
3436
        }
3437
        if (method_exists($qWrap, 'is_restored') && $qWrap->is_restored()) {
3438
            return $qWrap->destination_id;
3439
        }
3440
3441
        $surveyRepo = Container::getSurveyRepository();
3442
        $em = Database::getManager();
3443
3444
        $survey = $surveyRepo->find((int) $survey_id);
3445
        if (!$survey instanceof CSurvey) {
3446
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: target survey $survey_id not found.");
3447
3448
            return 0;
3449
        }
3450
3451
        $sid = (int) ($sid ?? api_get_session_id());
3452
3453
        $rewrite = function (?string $html, string $tag = '') use ($sid) {
3454
            if (null === $html || '' === $html) {
3455
                return '';
3456
            }
3457
3458
            return $this->rewriteHtmlForCourse((string) $html, $sid, $tag);
3459
        };
3460
3461
        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
3462
3463
        $questionText = $rewrite($q->survey_question ?? '', ':survey.q');
3464
        $commentText = $rewrite($q->survey_question_comment ?? '', ':survey.qc');
3465
3466
        try {
3467
            $question = new CSurveyQuestion();
3468
            $question
3469
                ->setSurvey($survey)
3470
                ->setSurveyQuestion($questionText)
3471
                ->setSurveyQuestionComment($commentText)
3472
                ->setType((string) ($q->survey_question_type ?? $q->type ?? 'open'))
3473
                ->setDisplay((string) ($q->display ?? 'vertical'))
3474
                ->setSort((int) ($q->sort ?? 0))
3475
            ;
3476
3477
            if (isset($q->shared_question_id) && method_exists($question, 'setSharedQuestionId')) {
3478
                $question->setSharedQuestionId((int) $q->shared_question_id);
3479
            }
3480
            if (isset($q->max_value) && method_exists($question, 'setMaxValue')) {
3481
                $question->setMaxValue((int) $q->max_value);
3482
            }
3483
            if (isset($q->is_required)) {
3484
                if (method_exists($question, 'setIsMandatory')) {
3485
                    $question->setIsMandatory((bool) $q->is_required);
3486
                } elseif (method_exists($question, 'setIsRequired')) {
3487
                    $question->setIsRequired((bool) $q->is_required);
3488
                }
3489
            }
3490
3491
            $em->persist($question);
3492
            $em->flush();
3493
3494
            // Options (value NOT NULL: default to 0 if missing)
3495
            $answers = \is_array($q->answers ?? null) ? $q->answers : [];
3496
            foreach ($answers as $idx => $answer) {
3497
                $optText = $rewrite($answer['option_text'] ?? '', ':survey.opt');
3498
                $value = isset($answer['value']) && null !== $answer['value'] ? (int) $answer['value'] : 0;
3499
                $sort = (int) ($answer['sort'] ?? ($idx + 1));
3500
3501
                $opt = new CSurveyQuestionOption();
3502
                $opt
3503
                    ->setSurvey($survey)
3504
                    ->setQuestion($question)
3505
                    ->setOptionText($optText)
3506
                    ->setSort($sort)
3507
                    ->setValue($value)
3508
                ;
3509
3510
                $em->persist($opt);
3511
            }
3512
            $em->flush();
3513
3514
            $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int) $question->getIid();
3515
3516
            return (int) $question->getIid();
3517
        } catch (Throwable $e) {
3518
            error_log('COURSE_DEBUG: restore_survey_question: failed: '.$e->getMessage());
3519
3520
            return 0;
3521
        }
3522
    }
3523
3524
    public function restore_learnpath_category(int $sessionId = 0, bool $baseContent = false): void
3525
    {
3526
        $reuseExisting = false;
3527
        if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing'])
3528
            && true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) {
3529
            $reuseExisting = true;
3530
        }
3531
3532
        if (!$this->course->has_resources(RESOURCE_LEARNPATH_CATEGORY)) {
3533
            return;
3534
        }
3535
3536
        $tblLpCategory = Database::get_course_table(TABLE_LP_CATEGORY);
3537
        $resources = $this->course->resources;
3538
3539
        /** @var LearnPathCategory $item */
3540
        foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) {
3541
            /** @var CLpCategory|null $lpCategory */
3542
            $lpCategory = $item->object;
3543
            if (!$lpCategory) {
3544
                continue;
3545
            }
3546
3547
            $title = trim((string) $lpCategory->getTitle());
3548
            if ('' === $title) {
3549
                continue;
3550
            }
3551
3552
            $categoryId = 0;
3553
3554
            $existing = Database::select(
3555
                'iid',
3556
                $tblLpCategory,
3557
                [
3558
                    'WHERE' => [
3559
                        'c_id = ? AND name = ?' => [$this->destination_course_id, $title],
3560
                    ],
3561
                ],
3562
                'first'
3563
            );
3564
3565
            if ($reuseExisting && !empty($existing) && !empty($existing['iid'])) {
3566
                $categoryId = (int) $existing['iid'];
3567
            } else {
3568
                $values = [
3569
                    'c_id' => $this->destination_course_id,
3570
                    'name' => $title,
3571
                ];
3572
                $categoryId = (int) learnpath::createCategory($values);
3573
            }
3574
3575
            if ($categoryId > 0) {
3576
                $this->course->resources[RESOURCE_LEARNPATH_CATEGORY][$id]->destination_id = $categoryId;
3577
            }
3578
        }
3579
    }
3580
3581
    /**
3582
     * Restore SCORM ZIPs under Documents (Learning paths) for traceability.
3583
     * Accepts real zips and on-the-fly temporary ones (temp will be deleted after upload).
3584
     */
3585
    public function restore_scorm_documents(): void
3586
    {
3587
        $logp = 'RESTORE_SCORM_ZIP: ';
3588
3589
        $getBucket = function (string $type) {
3590
            if (!empty($this->course->resources[$type]) && \is_array($this->course->resources[$type])) {
3591
                return $this->course->resources[$type];
3592
            }
3593
            foreach ($this->course->resources ?? [] as $k => $v) {
3594
                if (\is_string($k) && strtolower($k) === strtolower($type) && \is_array($v)) {
3595
                    return $v;
3596
                }
3597
            }
3598
3599
            return [];
3600
        };
3601
3602
        $docRepo = Container::getDocumentRepository();
3603
        $em = Database::getManager();
3604
3605
        $courseInfo = $this->destination_course_info;
3606
        if (empty($courseInfo) || empty($courseInfo['real_id'])) {
3607
            error_log($logp.'missing courseInfo/real_id');
3608
3609
            return;
3610
        }
3611
3612
        $courseEntity = api_get_course_entity((int) $courseInfo['real_id']);
3613
        if (!$courseEntity) {
3614
            error_log($logp.'api_get_course_entity failed');
3615
3616
            return;
3617
        }
3618
3619
        $sid = property_exists($this, 'current_session_id') ? (int) $this->current_session_id : 0;
3620
        $session = api_get_session_entity($sid);
3621
3622
        $entries = [];
3623
3624
        // A) direct SCORM bucket
3625
        $scormBucket = $getBucket(RESOURCE_SCORM);
3626
        foreach ($scormBucket as $sc) {
3627
            $entries[] = $sc;
3628
        }
3629
3630
        // B) also try LPs that are SCORM
3631
        $lpBucket = $getBucket(RESOURCE_LEARNPATH);
3632
        foreach ($lpBucket as $srcLpId => $lpObj) {
3633
            $lpType = (int) ($lpObj->lp_type ?? $lpObj->type ?? 1);
3634
            if (CLp::SCORM_TYPE === $lpType) {
3635
                $entries[] = (object) [
3636
                    'source_lp_id' => (int) $srcLpId,
3637
                    'lp_id_dest' => (int) ($lpObj->destination_id ?? 0),
3638
                ];
3639
            }
3640
        }
3641
3642
        error_log($logp.'entries='.\count($entries));
3643
        if (empty($entries)) {
3644
            return;
3645
        }
3646
3647
        $lpTop = $docRepo->ensureLearningPathSystemFolder($courseEntity, $session);
3648
3649
        foreach ($entries as $sc) {
3650
            // Locate package (zip or folder → temp zip)
3651
            $srcLpId = (int) ($sc->source_lp_id ?? 0);
3652
            $pkg = $this->findScormPackageForLp($srcLpId);
3653
            if (empty($pkg['zip'])) {
3654
                error_log($logp.'No package (zip/folder) found for a SCORM entry');
3655
3656
                continue;
3657
            }
3658
            $zipAbs = $pkg['zip'];
3659
            $zipTemp = (bool) $pkg['temp'];
3660
3661
            // Map LP title/dest for folder name
3662
            $lpId = 0;
3663
            $lpTitle = 'Untitled';
3664
            if (!empty($sc->lp_id_dest)) {
3665
                $lpId = (int) $sc->lp_id_dest;
3666
            } elseif ($srcLpId && !empty($lpBucket[$srcLpId]->destination_id)) {
3667
                $lpId = (int) $lpBucket[$srcLpId]->destination_id;
3668
            }
3669
            $lpEntity = $lpId ? Container::getLpRepository()->find($lpId) : null;
3670
            if ($lpEntity) {
3671
                $lpTitle = $lpEntity->getTitle() ?: $lpTitle;
3672
            }
3673
3674
            $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string) $lpTitle))) ?: 'Untitled';
3675
            $folderTitleBase = \sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle);
3676
            $folderTitle = $folderTitleBase;
3677
3678
            $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3679
            if ($exists) {
3680
                if (FILE_SKIP === $this->file_option) {
3681
                    error_log($logp."Skip due to folder name collision: '$folderTitle'");
3682
                    if ($zipTemp) {
3683
                        @unlink($zipAbs);
3684
                    }
3685
3686
                    continue;
3687
                }
3688
                if (FILE_RENAME === $this->file_option) {
3689
                    $i = 1;
3690
                    do {
3691
                        $folderTitle = $folderTitleBase.' ('.$i.')';
3692
                        $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3693
                        $i++;
3694
                    } while ($exists);
3695
                }
3696
                if (FILE_OVERWRITE === $this->file_option && $lpEntity) {
3697
                    $docRepo->purgeScormZip($courseEntity, $lpEntity);
3698
                    $em->flush();
3699
                }
3700
            }
3701
3702
            // Upload ZIP under Documents
3703
            $uploaded = new UploadedFile(
3704
                $zipAbs,
3705
                basename($zipAbs),
3706
                'application/zip',
3707
                null,
3708
                true
3709
            );
3710
            $lpFolder = $docRepo->ensureFolder(
3711
                $courseEntity,
3712
                $lpTop,
3713
                $folderTitle,
3714
                ResourceLink::VISIBILITY_DRAFT,
3715
                $session
3716
            );
3717
            $docRepo->createFileInFolder(
3718
                $courseEntity,
3719
                $lpFolder,
3720
                $uploaded,
3721
                \sprintf('SCORM ZIP for LP #%d', $lpId),
3722
                ResourceLink::VISIBILITY_DRAFT,
3723
                $session
3724
            );
3725
            $em->flush();
3726
3727
            if ($zipTemp) {
3728
                @unlink($zipAbs);
3729
            }
3730
            error_log($logp."ZIP stored under folder '$folderTitle'");
3731
        }
3732
    }
3733
3734
    /**
3735
     * Restore learnpaths with minimal dependencies hydration and robust path resolution.
3736
     *
3737
     * @param mixed $session_id
3738
     * @param mixed $respect_base_content
3739
     * @param mixed $destination_course_code
3740
     */
3741
    public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void
3742
    {
3743
        // 0) Ensure we have a resources snapshot (either internal or from the course)
3744
        $this->ensureDepsBagsFromSnapshot();
3745
        $all = $this->getAllResources(); // <- uses snapshot if available
3746
3747
        $docBag = $all[RESOURCE_DOCUMENT] ?? [];
3748
        $quizBag = $all[RESOURCE_QUIZ] ?? [];
3749
        $linkBag = $all[RESOURCE_LINK] ?? [];
3750
        $survBag = $all[RESOURCE_SURVEY] ?? [];
3751
        $workBag = $all[RESOURCE_WORK] ?? [];
3752
        $forumB = $all['forum'] ?? [];
3753
3754
        $this->dlog('LP: deps (after ensure/snapshot)', [
3755
            'document' => \count($docBag),
3756
            'quiz' => \count($quizBag),
3757
            'link' => \count($linkBag),
3758
            'student_publication' => \count($workBag),
3759
            'survey' => \count($survBag),
3760
            'forum' => \count($forumB),
3761
        ]);
3762
3763
        // Quick exit if no LPs selected
3764
        $lpBag = $this->course->resources[RESOURCE_LEARNPATH] ?? [];
3765
        if (empty($lpBag)) {
3766
            $this->dlog('LP: nothing to restore (bag is empty).');
3767
3768
            return;
3769
        }
3770
3771
        // Full snapshot to lookup deps without forcing user selection
3772
        // Must be available BEFORE filtering in the import controller (controller already forwards it).
3773
        $all = $this->getAllResources();
3774
3775
        // Map normalized resource types to bags (no extra validations)
3776
        $type2bags = [
3777
            'document' => ['document', RESOURCE_DOCUMENT],
3778
            'quiz' => ['quiz', RESOURCE_QUIZ],
3779
            'exercise' => ['quiz', RESOURCE_QUIZ],
3780
            'link' => ['link', RESOURCE_LINK],
3781
            'weblink' => ['link', RESOURCE_LINK],
3782
            'url' => ['link', RESOURCE_LINK],
3783
            'work' => ['works', RESOURCE_WORK],
3784
            'student_publication' => ['works', RESOURCE_WORK],
3785
            'survey' => ['survey', RESOURCE_SURVEY],
3786
            'forum' => ['forum', 'forum'],
3787
            // scorm/sco not handled here
3788
        ];
3789
3790
        // ID collectors per dependency kind
3791
        $need = [
3792
            RESOURCE_DOCUMENT => [],
3793
            RESOURCE_QUIZ => [],
3794
            RESOURCE_LINK => [],
3795
            RESOURCE_WORK => [],
3796
            RESOURCE_SURVEY => [],
3797
            'forum' => [],
3798
        ];
3799
3800
        $takeId = static function ($v) {
3801
            if (null === $v || '' === $v) {
3802
                return null;
3803
            }
3804
3805
            return ctype_digit((string) $v) ? (int) $v : null;
3806
        };
3807
3808
        // Collect deps from LP items
3809
        foreach ($lpBag as $srcLpId => $lpWrap) {
3810
            $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : [];
3811
            foreach ($items as $it) {
3812
                $itype = strtolower((string) ($it['item_type'] ?? ''));
3813
                $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? ''));
3814
                $id = $takeId($raw);
3815
3816
                if (null === $id) {
3817
                    continue;
3818
                }
3819
                if (!isset($type2bags[$itype])) {
3820
                    continue;
3821
                }
3822
3823
                [, $bag] = $type2bags[$itype];
3824
                $need[$bag][$id] = true;
3825
            }
3826
        }
3827
3828
        // Collect deps from linked_resources (export helper)
3829
        foreach ($lpBag as $srcLpId => $lpWrap) {
3830
            $linked = \is_array($lpWrap->linked_resources ?? null) ? $lpWrap->linked_resources : [];
3831
            foreach ($linked as $k => $ids) {
3832
                // normalize key to a known bag with $type2bags
3833
                $kk = strtolower($k);
3834
                if (isset($type2bags[$kk])) {
3835
                    [, $bag] = $type2bags[$kk];
3836
                } else {
3837
                    // sometimes exporter uses bag names directly (document/quiz/link/works/survey/forum)
3838
                    $bag = $kk;
3839
                }
3840
3841
                if (!isset($need[$bag])) {
3842
                    continue;
3843
                }
3844
                if (!\is_array($ids)) {
3845
                    continue;
3846
                }
3847
3848
                foreach ($ids as $legacyId) {
3849
                    $id = $takeId($legacyId);
3850
                    if (null !== $id) {
3851
                        $need[$bag][$id] = true;
3852
                    }
3853
                }
3854
            }
3855
        }
3856
3857
        // Build minimal bags from the snapshot using ONLY needed IDs
3858
        $filterBag = static function (array $sourceBag, array $idSet): array {
3859
            if (empty($idSet)) {
3860
                return [];
3861
            }
3862
            $out = [];
3863
            foreach ($idSet as $legacyId => $_) {
3864
                if (isset($sourceBag[$legacyId])) {
3865
                    $out[$legacyId] = $sourceBag[$legacyId];
3866
                }
3867
            }
3868
3869
            return $out;
3870
        };
3871
3872
        // Inject minimal bags only if the selected set didn't include them.
3873
        if (!isset($this->course->resources[RESOURCE_DOCUMENT])) {
3874
            $src = $all[RESOURCE_DOCUMENT] ?? [];
3875
            $this->course->resources[RESOURCE_DOCUMENT] = $filterBag($src, $need[RESOURCE_DOCUMENT]);
3876
        }
3877
        if (!isset($this->course->resources[RESOURCE_QUIZ])) {
3878
            $src = $all[RESOURCE_QUIZ] ?? [];
3879
            $this->course->resources[RESOURCE_QUIZ] = $filterBag($src, $need[RESOURCE_QUIZ]);
3880
            if (!empty($this->course->resources[RESOURCE_QUIZ])
3881
                && !isset($this->course->resources[RESOURCE_QUIZQUESTION])) {
3882
                $this->course->resources[RESOURCE_QUIZQUESTION] =
3883
                    $all[RESOURCE_QUIZQUESTION] ?? ($all['Exercise_Question'] ?? []);
3884
            }
3885
        }
3886
        if (!isset($this->course->resources[RESOURCE_LINK])) {
3887
            $src = $all[RESOURCE_LINK] ?? [];
3888
            $this->course->resources[RESOURCE_LINK] = $filterBag($src, $need[RESOURCE_LINK]);
3889
            if (!isset($this->course->resources[RESOURCE_LINKCATEGORY]) && isset($all[RESOURCE_LINKCATEGORY])) {
3890
                $this->course->resources[RESOURCE_LINKCATEGORY] = $all[RESOURCE_LINKCATEGORY];
3891
            }
3892
        }
3893
        if (!isset($this->course->resources[RESOURCE_WORK])) {
3894
            $src = $all[RESOURCE_WORK] ?? [];
3895
            $this->course->resources[RESOURCE_WORK] = $filterBag($src, $need[RESOURCE_WORK]);
3896
        }
3897
        if (!isset($this->course->resources[RESOURCE_SURVEY])) {
3898
            $src = $all[RESOURCE_SURVEY] ?? [];
3899
            $this->course->resources[RESOURCE_SURVEY] = $filterBag($src, $need[RESOURCE_SURVEY]);
3900
            if (!isset($this->course->resources[RESOURCE_SURVEYQUESTION]) && isset($all[RESOURCE_SURVEYQUESTION])) {
3901
                $this->course->resources[RESOURCE_SURVEYQUESTION] = $all[RESOURCE_SURVEYQUESTION];
3902
            }
3903
        }
3904
        if (!isset($this->course->resources['forum'])) {
3905
            $src = $all['forum'] ?? [];
3906
            $this->course->resources['forum'] = $filterBag($src, $need['forum']);
3907
            // minimal forum support if LP points to forums
3908
            if (!empty($this->course->resources['forum'])) {
3909
                foreach (['Forum_Category', 'thread', 'post'] as $k) {
3910
                    if (!isset($this->course->resources[$k]) && isset($all[$k])) {
3911
                        $this->course->resources[$k] = $all[$k];
3912
                    }
3913
                }
3914
            }
3915
        }
3916
3917
        $this->dlog('LP: minimal deps prepared', [
3918
            'document' => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []),
3919
            'quiz' => \count($this->course->resources[RESOURCE_QUIZ] ?? []),
3920
            'link' => \count($this->course->resources[RESOURCE_LINK] ?? []),
3921
            'student_publication' => \count($this->course->resources[RESOURCE_WORK] ?? []),
3922
            'survey' => \count($this->course->resources[RESOURCE_SURVEY] ?? []),
3923
            'forum' => \count($this->course->resources['forum'] ?? []),
3924
        ]);
3925
3926
        // --- 3) Restore ONLY those minimal bags ---
3927
        if (!empty($this->course->resources[RESOURCE_DOCUMENT])) {
3928
            $this->restore_documents($session_id, false, $destination_course_code);
3929
        }
3930
        if (!empty($this->course->resources[RESOURCE_QUIZ])) {
3931
            $this->restore_quizzes($session_id, false);
3932
        }
3933
        if (!empty($this->course->resources[RESOURCE_LINK])) {
3934
            $this->restore_links($session_id);
3935
        }
3936
        if (!empty($this->course->resources[RESOURCE_WORK])) {
3937
            $this->restore_works($session_id);
3938
        }
3939
        if (!empty($this->course->resources[RESOURCE_SURVEY])) {
3940
            $this->restore_surveys($session_id);
3941
        }
3942
        if (!empty($this->course->resources['forum'])) {
3943
            $this->restore_forums($session_id);
3944
        }
3945
3946
        // --- 4) Create LP + items with resolved paths to new destination iids ---
3947
        $em = Database::getManager();
3948
        $courseEnt = api_get_course_entity($this->destination_course_id);
3949
        $sessionEnt = api_get_session_entity((int) $session_id);
3950
        $lpRepo = Container::getLpRepository();
3951
        $lpItemRepo = Container::getLpItemRepository();
3952
        $docRepo = Container::getDocumentRepository();
3953
3954
        // Optional repos for title fallbacks
3955
        $quizRepo = method_exists(Container::class, 'getQuizRepository') ? Container::getQuizRepository() : null;
3956
        $linkRepo = method_exists(Container::class, 'getLinkRepository') ? Container::getLinkRepository() : null;
3957
        $forumRepo = method_exists(Container::class, 'getForumRepository') ? Container::getForumRepository() : null;
3958
        $surveyRepo = method_exists(Container::class, 'getSurveyRepository') ? Container::getSurveyRepository() : null;
3959
        $workRepo = method_exists(Container::class, 'getStudentPublicationRepository') ? Container::getStudentPublicationRepository() : null;
3960
3961
        $getDst = function (string $bag, $legacyId): int {
3962
            $wrap = $this->course->resources[$bag][$legacyId] ?? null;
3963
3964
            return $wrap && isset($wrap->destination_id) ? (int) $wrap->destination_id : 0;
3965
        };
3966
3967
        $findDocIidByTitle = function (string $title) use ($docRepo, $courseEnt, $sessionEnt): int {
3968
            if ('' === $title) {
3969
                return 0;
3970
            }
3971
3972
            try {
3973
                $hit = $docRepo->findCourseResourceByTitle(
3974
                    $title,
3975
                    $courseEnt->getResourceNode(),
3976
                    $courseEnt,
3977
                    $sessionEnt,
3978
                    api_get_group_entity()
3979
                );
3980
3981
                return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
3982
            } catch (Throwable $e) {
3983
                $this->dlog('LP: doc title lookup failed', ['title' => $title, 'err' => $e->getMessage()]);
3984
3985
                return 0;
3986
            }
3987
        };
3988
3989
        // Generic title finders (defensive: method_exists checks)
3990
        $findByTitle = [
3991
            'quiz' => function (string $title) use ($quizRepo, $courseEnt, $sessionEnt): int {
3992
                if (!$quizRepo || '' === $title) {
3993
                    return 0;
3994
                }
3995
3996
                try {
3997
                    $hit = method_exists($quizRepo, 'findOneByTitleInCourse')
3998
                        ? $quizRepo->findOneByTitleInCourse($title, $courseEnt, $sessionEnt)
3999
                        : $quizRepo->findOneBy(['title' => $title]);
4000
4001
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4002
                } catch (Throwable $e) {
4003
                    return 0;
4004
                }
4005
            },
4006
            'link' => function (string $title) use ($linkRepo, $courseEnt): int {
4007
                if (!$linkRepo || '' === $title) {
4008
                    return 0;
4009
                }
4010
4011
                try {
4012
                    $hit = method_exists($linkRepo, 'findOneByTitleInCourse')
4013
                        ? $linkRepo->findOneByTitleInCourse($title, $courseEnt, null)
4014
                        : $linkRepo->findOneBy(['title' => $title]);
4015
4016
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4017
                } catch (Throwable $e) {
4018
                    return 0;
4019
                }
4020
            },
4021
            'forum' => function (string $title) use ($forumRepo, $courseEnt): int {
4022
                if (!$forumRepo || '' === $title) {
4023
                    return 0;
4024
                }
4025
4026
                try {
4027
                    $hit = method_exists($forumRepo, 'findOneByTitleInCourse')
4028
                        ? $forumRepo->findOneByTitleInCourse($title, $courseEnt, null)
4029
                        : $forumRepo->findOneBy(['forum_title' => $title]) ?? $forumRepo->findOneBy(['title' => $title]);
4030
4031
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4032
                } catch (Throwable $e) {
4033
                    return 0;
4034
                }
4035
            },
4036
            'survey' => function (string $title) use ($surveyRepo, $courseEnt): int {
4037
                if (!$surveyRepo || '' === $title) {
4038
                    return 0;
4039
                }
4040
4041
                try {
4042
                    $hit = method_exists($surveyRepo, 'findOneByTitleInCourse')
4043
                        ? $surveyRepo->findOneByTitleInCourse($title, $courseEnt, null)
4044
                        : $surveyRepo->findOneBy(['title' => $title]);
4045
4046
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4047
                } catch (Throwable $e) {
4048
                    return 0;
4049
                }
4050
            },
4051
            'work' => function (string $title) use ($workRepo, $courseEnt): int {
4052
                if (!$workRepo || '' === $title) {
4053
                    return 0;
4054
                }
4055
4056
                try {
4057
                    $hit = method_exists($workRepo, 'findOneByTitleInCourse')
4058
                        ? $workRepo->findOneByTitleInCourse($title, $courseEnt, null)
4059
                        : $workRepo->findOneBy(['title' => $title]);
4060
4061
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4062
                } catch (Throwable $e) {
4063
                    return 0;
4064
                }
4065
            },
4066
        ];
4067
4068
        $resolvePath = function (array $it) use ($getDst, $findDocIidByTitle, $findByTitle): string {
4069
            $itype = strtolower((string) ($it['item_type'] ?? ''));
4070
            $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? ''));
4071
            $title = trim((string) ($it['title'] ?? ''));
4072
4073
            switch ($itype) {
4074
                case 'document':
4075
                    if (ctype_digit((string) $raw)) {
4076
                        $nid = $getDst(RESOURCE_DOCUMENT, (int) $raw);
4077
4078
                        return $nid ? (string) $nid : '';
4079
                    }
4080
                    if (\is_string($raw) && str_starts_with((string) $raw, 'document/')) {
4081
                        return (string) $raw;
4082
                    }
4083
                    $maybe = $findDocIidByTitle('' !== $title ? $title : (string) $raw);
4084
4085
                    return $maybe ? (string) $maybe : '';
4086
4087
                case 'quiz':
4088
                case 'exercise':
4089
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4090
                    $nid = $id ? $getDst(RESOURCE_QUIZ, $id) : 0;
4091
                    if ($nid) {
4092
                        return (string) $nid;
4093
                    }
4094
                    $nid = $findByTitle['quiz']('' !== $title ? $title : (string) $raw);
4095
4096
                    return $nid ? (string) $nid : '';
4097
4098
                case 'link':
4099
                case 'weblink':
4100
                case 'url':
4101
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4102
                    $nid = $id ? $getDst(RESOURCE_LINK, $id) : 0;
4103
                    if ($nid) {
4104
                        return (string) $nid;
4105
                    }
4106
                    $nid = $findByTitle['link']('' !== $title ? $title : (string) $raw);
4107
4108
                    return $nid ? (string) $nid : '';
4109
4110
                case 'work':
4111
                case 'student_publication':
4112
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4113
                    $nid = $id ? $getDst(RESOURCE_WORK, $id) : 0;
4114
                    if ($nid) {
4115
                        return (string) $nid;
4116
                    }
4117
                    $nid = $findByTitle['work']('' !== $title ? $title : (string) $raw);
4118
4119
                    return $nid ? (string) $nid : '';
4120
4121
                case 'survey':
4122
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4123
                    $nid = $id ? $getDst(RESOURCE_SURVEY, $id) : 0;
4124
                    if ($nid) {
4125
                        return (string) $nid;
4126
                    }
4127
                    $nid = $findByTitle['survey']('' !== $title ? $title : (string) $raw);
4128
4129
                    return $nid ? (string) $nid : '';
4130
4131
                case 'forum':
4132
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4133
                    $nid = $id ? $getDst('forum', $id) : 0;
4134
                    if ($nid) {
4135
                        return (string) $nid;
4136
                    }
4137
                    $nid = $findByTitle['forum']('' !== $title ? $title : (string) $raw);
4138
4139
                    return $nid ? (string) $nid : '';
4140
4141
                default:
4142
                    // keep whatever was exported
4143
                    return (string) $raw;
4144
            }
4145
        };
4146
4147
        foreach ($lpBag as $srcLpId => $lpWrap) {
4148
            $title = (string) ($lpWrap->name ?? $lpWrap->title ?? ('LP '.$srcLpId));
4149
            $desc = (string) ($lpWrap->description ?? '');
4150
            $lpType = (int) ($lpWrap->lp_type ?? $lpWrap->type ?? 1);
4151
4152
            $lp = (new CLp())
4153
                ->setLpType($lpType)
4154
                ->setTitle($title)
4155
                ->setParent($courseEnt)
4156
            ;
4157
4158
            if (method_exists($lp, 'addCourseLink')) {
4159
                $lp->addCourseLink($courseEnt, $sessionEnt);
4160
            }
4161
            if (method_exists($lp, 'setDescription')) {
4162
                $lp->setDescription($desc);
4163
            }
4164
4165
            $lpRepo->createLp($lp);
4166
            $em->flush();
4167
4168
            $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id = (int) $lp->getIid();
4169
4170
            $root = $lpItemRepo->getRootItem($lp->getIid());
4171
            $parents = [0 => $root];
4172
            $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : [];
4173
            $order = 0;
4174
4175
            foreach ($items as $it) {
4176
                $lvl = (int) ($it['level'] ?? 0);
4177
                $pItem = $parents[$lvl] ?? $root;
4178
4179
                $itype = (string) ($it['item_type'] ?? 'dir');
4180
                $itTitle = (string) ($it['title'] ?? '');
4181
                $path = $resolvePath($it);
4182
4183
                $item = (new CLpItem())
4184
                    ->setLp($lp)
4185
                    ->setParent($pItem)
4186
                    ->setItemType($itype)
4187
                    ->setTitle($itTitle)
4188
                    ->setPath($path)
4189
                    ->setRef((string) ($it['identifier'] ?? ''))
4190
                    ->setDisplayOrder(++$order)
4191
                ;
4192
4193
                if (isset($it['parameters'])) {
4194
                    $item->setParameters((string) $it['parameters']);
4195
                }
4196
                if (isset($it['prerequisite'])) {
4197
                    $item->setPrerequisite((string) $it['prerequisite']);
4198
                }
4199
                if (isset($it['launch_data'])) {
4200
                    $item->setLaunchData((string) $it['launch_data']);
4201
                }
4202
4203
                $lpItemRepo->create($item);
4204
                $parents[$lvl + 1] = $item;
4205
            }
4206
4207
            $em->flush();
4208
4209
            $this->dlog('LP: items created', [
4210
                'lp_iid' => (int) $lp->getIid(),
4211
                'items' => $order,
4212
                'title' => $title,
4213
            ]);
4214
        }
4215
    }
4216
4217
    /**
4218
     * Restore Glossary resources for the destination course.
4219
     *
4220
     * @param mixed $sessionId
4221
     */
4222
    public function restore_glossary($sessionId = 0): void
4223
    {
4224
        if (!$this->course->has_resources(RESOURCE_GLOSSARY)) {
4225
            $this->debug && error_log('COURSE_DEBUG: restore_glossary: no glossary resources in backup.');
4226
4227
            return;
4228
        }
4229
4230
        $em = Database::getManager();
4231
4232
        /** @var CGlossaryRepository $repo */
4233
        $repo = $em->getRepository(CGlossary::class);
4234
4235
        /** @var CourseEntity $courseEntity */
4236
        $courseEntity = api_get_course_entity($this->destination_course_id);
4237
4238
        /** @var SessionEntity|null $sessionEntity */
4239
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4240
4241
        $resources = $this->course->resources;
4242
4243
        foreach ($resources[RESOURCE_GLOSSARY] as $legacyId => $gls) {
4244
            try {
4245
                $title = (string) ($gls->name ?? $gls->title ?? '');
4246
                $desc = (string) ($gls->description ?? '');
4247
                $order = (int) ($gls->display_order ?? 0);
4248
4249
                // Normalize title
4250
                if ('' === $title) {
4251
                    $title = 'Glossary term';
4252
                }
4253
4254
                // HTML rewrite (always)
4255
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[glossary.term]');
4256
4257
                // Look up existing by title in this course + (optional) session
4258
                if (method_exists($repo, 'getResourcesByCourse')) {
4259
                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
4260
                        ->andWhere('resource.title = :title')->setParameter('title', $title)
4261
                        ->setMaxResults(1)
4262
                    ;
4263
                    $existing = $qb->getQuery()->getOneOrNullResult();
4264
                } else {
4265
                    $existing = $repo->findOneBy(['title' => $title]);
4266
                }
4267
4268
                if ($existing instanceof CGlossary) {
4269
                    switch ($this->file_option) {
4270
                        case FILE_SKIP:
4271
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4272
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid();
4273
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: exists title='{$title}' (skip).");
4274
4275
                            continue 2;
4276
4277
                        case FILE_RENAME:
4278
                            // Generate a unique title inside the course/session
4279
                            $base = $title;
4280
                            $try = $base;
4281
                            $i = 1;
4282
                            $isTaken = static function ($repo, $courseEntity, $sessionEntity, $titleTry) {
4283
                                if (method_exists($repo, 'getResourcesByCourse')) {
4284
                                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
4285
                                        ->andWhere('resource.title = :t')->setParameter('t', $titleTry)
4286
                                        ->setMaxResults(1)
4287
                                    ;
4288
4289
                                    return (bool) $qb->getQuery()->getOneOrNullResult();
4290
                                }
4291
4292
                                return (bool) $repo->findOneBy(['title' => $titleTry]);
4293
                            };
4294
                            while ($isTaken($repo, $courseEntity, $sessionEntity, $try)) {
4295
                                $try = $base.' ('.($i++).')';
4296
                            }
4297
                            $title = $try;
4298
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: renaming to '{$title}'.");
4299
4300
                            break;
4301
4302
                        case FILE_OVERWRITE:
4303
                            $em->remove($existing);
4304
                            $em->flush();
4305
                            $this->debug && error_log('COURSE_DEBUG: restore_glossary: existing term deleted (overwrite).');
4306
4307
                            break;
4308
4309
                        default:
4310
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4311
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid();
4312
4313
                            continue 2;
4314
                    }
4315
                }
4316
4317
                // Create
4318
                $entity = (new CGlossary())
4319
                    ->setTitle($title)
4320
                    ->setDescription($desc)
4321
                ;
4322
4323
                if (method_exists($entity, 'setParent')) {
4324
                    $entity->setParent($courseEntity);
4325
                }
4326
                if (method_exists($entity, 'addCourseLink')) {
4327
                    $entity->addCourseLink($courseEntity, $sessionEntity);
4328
                }
4329
4330
                if (method_exists($repo, 'create')) {
4331
                    $repo->create($entity);
4332
                } else {
4333
                    $em->persist($entity);
4334
                    $em->flush();
4335
                }
4336
4337
                if ($order && method_exists($entity, 'setDisplayOrder')) {
4338
                    $entity->setDisplayOrder($order);
4339
                    $em->flush();
4340
                }
4341
4342
                $newId = (int) $entity->getIid();
4343
                $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4344
                $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = $newId;
4345
4346
                $this->debug && error_log("COURSE_DEBUG: restore_glossary: created term iid={$newId}, title='{$title}'");
4347
            } catch (Throwable $e) {
4348
                error_log('COURSE_DEBUG: restore_glossary: failed: '.$e->getMessage());
4349
4350
                continue;
4351
            }
4352
        }
4353
    }
4354
4355
    /**
4356
     * Restore Wiki resources for the destination course.
4357
     *
4358
     * @param mixed $sessionId
4359
     */
4360
    public function restore_wiki($sessionId = 0): void
4361
    {
4362
        if (!$this->course->has_resources(RESOURCE_WIKI)) {
4363
            $this->debug && error_log('COURSE_DEBUG: restore_wiki: no wiki resources in backup.');
4364
4365
            return;
4366
        }
4367
4368
        $em = Database::getManager();
4369
4370
        /** @var CWikiRepository $repo */
4371
        $repo = $em->getRepository(CWiki::class);
4372
4373
        /** @var CourseEntity $courseEntity */
4374
        $courseEntity = api_get_course_entity($this->destination_course_id);
4375
4376
        /** @var SessionEntity|null $sessionEntity */
4377
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4378
4379
        $cid = (int) $this->destination_course_id;
4380
        $sid = (int) ($sessionEntity?->getId() ?? 0);
4381
4382
        $resources = $this->course->resources;
4383
4384
        foreach ($resources[RESOURCE_WIKI] as $legacyId => $w) {
4385
            try {
4386
                $rawTitle = (string) ($w->title ?? $w->name ?? '');
4387
                $reflink = (string) ($w->reflink ?? '');
4388
                $content = (string) ($w->content ?? '');
4389
                $comment = (string) ($w->comment ?? '');
4390
                $progress = (string) ($w->progress ?? '');
4391
                $version = (int) ($w->version ?? 1);
4392
                $groupId = (int) ($w->group_id ?? 0);
4393
                $userId = (int) ($w->user_id ?? api_get_user_id());
4394
4395
                // HTML rewrite
4396
                $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[wiki.page]');
4397
4398
                if ('' === $rawTitle) {
4399
                    $rawTitle = 'Wiki page';
4400
                }
4401
                if ('' === $content) {
4402
                    $content = '<p>&nbsp;</p>';
4403
                }
4404
4405
                // slug maker
4406
                $makeSlug = static function (string $s): string {
4407
                    $s = strtolower(trim($s));
4408
                    $s = preg_replace('/[^\p{L}\p{N}]+/u', '-', $s) ?: '';
4409
                    $s = trim($s, '-');
4410
4411
                    return '' === $s ? 'page' : $s;
4412
                };
4413
                $reflink = '' !== $reflink ? $makeSlug($reflink) : $makeSlug($rawTitle);
4414
4415
                // existence check
4416
                $qbExists = $repo->createQueryBuilder('w')
4417
                    ->select('w.iid')
4418
                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4419
                    ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4420
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4421
                ;
4422
                if ($sid > 0) {
4423
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4424
                } else {
4425
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = 0');
4426
                }
4427
4428
                $exists = (bool) $qbExists->getQuery()->getOneOrNullResult();
4429
4430
                if ($exists) {
4431
                    switch ($this->file_option) {
4432
                        case FILE_SKIP:
4433
                            // map to latest page id
4434
                            $qbLast = $repo->createQueryBuilder('w')
4435
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4436
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4437
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4438
                                ->orderBy('w.version', 'DESC')->setMaxResults(1)
4439
                            ;
4440
                            if ($sid > 0) {
4441
                                $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4442
                            } else {
4443
                                $qbLast->andWhere('COALESCE(w.sessionId,0) = 0');
4444
                            }
4445
4446
                            /** @var CWiki|null $last */
4447
                            $last = $qbLast->getQuery()->getOneOrNullResult();
4448
                            $dest = $last ? (int) ($last->getPageId() ?: $last->getIid()) : 0;
4449
4450
                            $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass();
4451
                            $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = $dest;
4452
4453
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: exists → skip (page_id={$dest}).");
4454
4455
                            continue 2;
4456
4457
                        case FILE_RENAME:
4458
                            $baseSlug = $reflink;
4459
                            $baseTitle = $rawTitle;
4460
                            $i = 1;
4461
                            $trySlug = $baseSlug.'-'.$i;
4462
                            $isTaken = function (string $slug) use ($repo, $cid, $sid, $groupId): bool {
4463
                                $qb = $repo->createQueryBuilder('w')
4464
                                    ->select('w.iid')
4465
                                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4466
                                    ->andWhere('w.reflink = :r')->setParameter('r', $slug)
4467
                                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4468
                                ;
4469
                                if ($sid > 0) {
4470
                                    $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4471
                                } else {
4472
                                    $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4473
                                }
4474
                                $qb->setMaxResults(1);
4475
4476
                                return (bool) $qb->getQuery()->getOneOrNullResult();
4477
                            };
4478
                            while ($isTaken($trySlug)) {
4479
                                $trySlug = $baseSlug.'-'.(++$i);
4480
                            }
4481
                            $reflink = $trySlug;
4482
                            $rawTitle = $baseTitle.' ('.$i.')';
4483
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed to '{$reflink}' / '{$rawTitle}'.");
4484
4485
                            break;
4486
4487
                        case FILE_OVERWRITE:
4488
                            $qbAll = $repo->createQueryBuilder('w')
4489
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4490
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4491
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4492
                            ;
4493
                            if ($sid > 0) {
4494
                                $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4495
                            } else {
4496
                                $qbAll->andWhere('COALESCE(w.sessionId,0) = 0');
4497
                            }
4498
                            foreach ($qbAll->getQuery()->getResult() as $old) {
4499
                                $em->remove($old);
4500
                            }
4501
                            $em->flush();
4502
                            $this->debug && error_log('COURSE_DEBUG: restore_wiki: removed old pages (overwrite).');
4503
4504
                            break;
4505
4506
                        default:
4507
                            $this->debug && error_log('COURSE_DEBUG: restore_wiki: unknown file_option → skip.');
4508
4509
                            continue 2;
4510
                    }
4511
                }
4512
4513
                // Create new page (one version)
4514
                $wiki = new CWiki();
4515
                $wiki->setCId($cid);
4516
                $wiki->setSessionId($sid);
4517
                $wiki->setGroupId($groupId);
4518
                $wiki->setReflink($reflink);
4519
                $wiki->setTitle($rawTitle);
4520
                $wiki->setContent($content);  // already rewritten
4521
                $wiki->setComment($comment);
4522
                $wiki->setProgress($progress);
4523
                $wiki->setVersion($version > 0 ? $version : 1);
4524
                $wiki->setUserId($userId);
4525
4526
                // timestamps
4527
                try {
4528
                    $dtimeStr = (string) ($w->dtime ?? '');
4529
                    $wiki->setDtime('' !== $dtimeStr ? new DateTime($dtimeStr) : new DateTime('now', new DateTimeZone('UTC')));
4530
                } catch (Throwable) {
4531
                    $wiki->setDtime(new DateTime('now', new DateTimeZone('UTC')));
4532
                }
4533
4534
                $wiki->setIsEditing(0);
4535
                $wiki->setTimeEdit(null);
4536
                $wiki->setHits((int) ($w->hits ?? 0));
4537
                $wiki->setAddlock((int) ($w->addlock ?? 1));
4538
                $wiki->setEditlock((int) ($w->editlock ?? 0));
4539
                $wiki->setVisibility((int) ($w->visibility ?? 1));
4540
                $wiki->setAddlockDisc((int) ($w->addlock_disc ?? 1));
4541
                $wiki->setVisibilityDisc((int) ($w->visibility_disc ?? 1));
4542
                $wiki->setRatinglockDisc((int) ($w->ratinglock_disc ?? 1));
4543
                $wiki->setAssignment((int) ($w->assignment ?? 0));
4544
                $wiki->setScore(isset($w->score) ? (int) $w->score : 0);
4545
                $wiki->setLinksto((string) ($w->linksto ?? ''));
4546
                $wiki->setTag((string) ($w->tag ?? ''));
4547
                $wiki->setUserIp((string) ($w->user_ip ?? api_get_real_ip()));
4548
4549
                if (method_exists($wiki, 'setParent')) {
4550
                    $wiki->setParent($courseEntity);
4551
                }
4552
                if (method_exists($wiki, 'setCreator')) {
4553
                    $wiki->setCreator(api_get_user_entity());
4554
                }
4555
                $groupEntity = $groupId ? api_get_group_entity($groupId) : null;
4556
                if (method_exists($wiki, 'addCourseLink')) {
4557
                    $wiki->addCourseLink($courseEntity, $sessionEntity, $groupEntity);
4558
                }
4559
4560
                $em->persist($wiki);
4561
                $em->flush();
4562
4563
                // Page id
4564
                if (empty($w->page_id)) {
4565
                    $wiki->setPageId((int) $wiki->getIid());
4566
                } else {
4567
                    $pid = (int) $w->page_id;
4568
                    $wiki->setPageId($pid > 0 ? $pid : (int) $wiki->getIid());
4569
                }
4570
                $em->flush();
4571
4572
                // Conf row
4573
                $conf = new CWikiConf();
4574
                $conf->setCId($cid);
4575
                $conf->setPageId((int) $wiki->getPageId());
4576
                $conf->setTask((string) ($w->task ?? ''));
4577
                $conf->setFeedback1((string) ($w->feedback1 ?? ''));
4578
                $conf->setFeedback2((string) ($w->feedback2 ?? ''));
4579
                $conf->setFeedback3((string) ($w->feedback3 ?? ''));
4580
                $conf->setFprogress1((string) ($w->fprogress1 ?? ''));
4581
                $conf->setFprogress2((string) ($w->fprogress2 ?? ''));
4582
                $conf->setFprogress3((string) ($w->fprogress3 ?? ''));
4583
                $conf->setMaxText(isset($w->max_text) ? (int) $w->max_text : 0);
4584
                $conf->setMaxVersion(isset($w->max_version) ? (int) $w->max_version : 0);
4585
4586
                try {
4587
                    $conf->setStartdateAssig(!empty($w->startdate_assig) ? new DateTime((string) $w->startdate_assig) : null);
4588
                } catch (Throwable) {
4589
                    $conf->setStartdateAssig(null);
4590
                }
4591
4592
                try {
4593
                    $conf->setEnddateAssig(!empty($w->enddate_assig) ? new DateTime((string) $w->enddate_assig) : null);
4594
                } catch (Throwable) {
4595
                    $conf->setEnddateAssig(null);
4596
                }
4597
                $conf->setDelayedsubmit(isset($w->delayedsubmit) ? (int) $w->delayedsubmit : 0);
4598
4599
                $em->persist($conf);
4600
                $em->flush();
4601
4602
                $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass();
4603
                $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = (int) $wiki->getPageId();
4604
4605
                $this->debug && error_log('COURSE_DEBUG: restore_wiki: created page iid='.(int) $wiki->getIid().' page_id='.(int) $wiki->getPageId()." reflink='{$reflink}'");
4606
            } catch (Throwable $e) {
4607
                error_log('COURSE_DEBUG: restore_wiki: failed: '.$e->getMessage());
4608
4609
                continue;
4610
            }
4611
        }
4612
    }
4613
4614
    /**
4615
     * Restore "Thematic" resources for the destination course.
4616
     *
4617
     * @param mixed $sessionId
4618
     */
4619
    public function restore_thematic($sessionId = 0): void
4620
    {
4621
        if (!$this->course->has_resources(RESOURCE_THEMATIC)) {
4622
            $this->debug && error_log('COURSE_DEBUG: restore_thematic: no thematic resources.');
4623
4624
            return;
4625
        }
4626
4627
        $em = Database::getManager();
4628
4629
        /** @var CourseEntity $courseEntity */
4630
        $courseEntity = api_get_course_entity($this->destination_course_id);
4631
4632
        /** @var SessionEntity|null $sessionEntity */
4633
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4634
4635
        $resources = $this->course->resources;
4636
4637
        foreach ($resources[RESOURCE_THEMATIC] as $legacyId => $t) {
4638
            try {
4639
                $p = (array) ($t->params ?? []);
4640
4641
                $title = trim((string) ($p['title'] ?? $p['name'] ?? ''));
4642
                $content = (string) ($p['content'] ?? '');
4643
                $active = (bool) ($p['active'] ?? true);
4644
4645
                if ('' === $title) {
4646
                    $title = 'Thematic';
4647
                }
4648
4649
                // Rewrite embedded HTML so referenced files/images are valid in the new course
4650
                $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[thematic.main]');
4651
4652
                // Create Thematic root
4653
                $thematic = (new CThematic())
4654
                    ->setTitle($title)
4655
                    ->setContent($content)
4656
                    ->setActive($active)
4657
                ;
4658
4659
                // Set ownership and course linkage if available
4660
                if (method_exists($thematic, 'setParent')) {
4661
                    $thematic->setParent($courseEntity);
4662
                }
4663
                if (method_exists($thematic, 'setCreator')) {
4664
                    $thematic->setCreator(api_get_user_entity());
4665
                }
4666
                if (method_exists($thematic, 'addCourseLink')) {
4667
                    $thematic->addCourseLink($courseEntity, $sessionEntity);
4668
                }
4669
4670
                $em->persist($thematic);
4671
                $em->flush();
4672
4673
                // Map new IID back to resources
4674
                $this->course->resources[RESOURCE_THEMATIC][$legacyId] ??= new stdClass();
4675
                $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int) $thematic->getIid();
4676
4677
                // Restore "advances" (timeline slots)
4678
                $advList = (array) ($t->thematic_advance_list ?? []);
4679
                foreach ($advList as $adv) {
4680
                    if (!\is_array($adv)) {
4681
                        $adv = (array) $adv;
4682
                    }
4683
4684
                    $advContent = (string) ($adv['content'] ?? '');
4685
                    // Rewrite HTML inside advance content
4686
                    $advContent = $this->rewriteHtmlForCourse($advContent, (int) $sessionId, '[thematic.advance]');
4687
4688
                    $rawStart = (string) ($adv['start_date'] ?? $adv['startDate'] ?? '');
4689
4690
                    try {
4691
                        $startDate = '' !== $rawStart ? new DateTime($rawStart) : new DateTime('now', new DateTimeZone('UTC'));
4692
                    } catch (Throwable) {
4693
                        $startDate = new DateTime('now', new DateTimeZone('UTC'));
4694
                    }
4695
4696
                    $duration = (int) ($adv['duration'] ?? 1);
4697
                    $doneAdvance = (bool) ($adv['done_advance'] ?? $adv['doneAdvance'] ?? false);
4698
4699
                    $advance = (new CThematicAdvance())
4700
                        ->setThematic($thematic)
4701
                        ->setContent($advContent)
4702
                        ->setStartDate($startDate)
4703
                        ->setDuration($duration)
4704
                        ->setDoneAdvance($doneAdvance)
4705
                    ;
4706
4707
                    // Optional links to attendance/room if present
4708
                    $attId = (int) ($adv['attendance_id'] ?? 0);
4709
                    if ($attId > 0) {
4710
                        $att = $em->getRepository(CAttendance::class)->find($attId);
4711
                        if ($att) {
4712
                            $advance->setAttendance($att);
4713
                        }
4714
                    }
4715
                    $roomId = (int) ($adv['room_id'] ?? 0);
4716
                    if ($roomId > 0) {
4717
                        $room = $em->getRepository(Room::class)->find($roomId);
4718
                        if ($room) {
4719
                            $advance->setRoom($room);
4720
                        }
4721
                    }
4722
4723
                    $em->persist($advance);
4724
                }
4725
4726
                // Restore "plans" (structured descriptions)
4727
                $planList = (array) ($t->thematic_plan_list ?? []);
4728
                foreach ($planList as $pl) {
4729
                    if (!\is_array($pl)) {
4730
                        $pl = (array) $pl;
4731
                    }
4732
4733
                    $plTitle = trim((string) ($pl['title'] ?? ''));
4734
                    if ('' === $plTitle) {
4735
                        $plTitle = 'Plan';
4736
                    }
4737
4738
                    $plDesc = (string) ($pl['description'] ?? '');
4739
                    // Rewrite HTML inside plan description
4740
                    $plDesc = $this->rewriteHtmlForCourse($plDesc, (int) $sessionId, '[thematic.plan]');
4741
4742
                    $descType = (int) ($pl['description_type'] ?? $pl['descriptionType'] ?? 0);
4743
4744
                    $plan = (new CThematicPlan())
4745
                        ->setThematic($thematic)
4746
                        ->setTitle($plTitle)
4747
                        ->setDescription($plDesc)
4748
                        ->setDescriptionType($descType)
4749
                    ;
4750
4751
                    $em->persist($plan);
4752
                }
4753
4754
                // Flush once per thematic (advances + plans)
4755
                $em->flush();
4756
4757
                $this->debug && error_log(
4758
                    'COURSE_DEBUG: restore_thematic: created thematic iid='.(int) $thematic->getIid().
4759
                    ' (advances='.\count($advList).', plans='.\count($planList).')'
4760
                );
4761
            } catch (Throwable $e) {
4762
                error_log('COURSE_DEBUG: restore_thematic: failed: '.$e->getMessage());
4763
4764
                continue;
4765
            }
4766
        }
4767
    }
4768
4769
    /**
4770
     * Restore "Attendance" resources (register + calendar slots).
4771
     *
4772
     * @param mixed $sessionId
4773
     */
4774
    public function restore_attendance($sessionId = 0): void
4775
    {
4776
        if (!$this->course->has_resources(RESOURCE_ATTENDANCE)) {
4777
            $this->debug && error_log('COURSE_DEBUG: restore_attendance: no attendance resources.');
4778
4779
            return;
4780
        }
4781
4782
        $em = Database::getManager();
4783
4784
        /** @var CourseEntity $courseEntity */
4785
        $courseEntity = api_get_course_entity($this->destination_course_id);
4786
4787
        /** @var SessionEntity|null $sessionEntity */
4788
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4789
4790
        $resources = $this->course->resources;
4791
4792
        foreach ($resources[RESOURCE_ATTENDANCE] as $legacyId => $att) {
4793
            try {
4794
                $p = (array) ($att->params ?? []);
4795
4796
                $title = trim((string) ($p['title'] ?? 'Attendance'));
4797
                $desc = (string) ($p['description'] ?? '');
4798
                $active = (int) ($p['active'] ?? 1);
4799
4800
                // Normalize title
4801
                if ('' === $title) {
4802
                    $title = 'Attendance';
4803
                }
4804
4805
                // Rewrite HTML in description (links to course files, etc.)
4806
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[attendance.main]');
4807
4808
                // Optional grading attributes
4809
                $qualTitle = isset($p['attendance_qualify_title']) ? (string) $p['attendance_qualify_title'] : null;
4810
                $qualMax = (int) ($p['attendance_qualify_max'] ?? 0);
4811
                $weight = (float) ($p['attendance_weight'] ?? 0.0);
4812
                $locked = (int) ($p['locked'] ?? 0);
4813
4814
                // Create attendance entity
4815
                $a = (new CAttendance())
4816
                    ->setTitle($title)
4817
                    ->setDescription($desc)
4818
                    ->setActive($active)
4819
                    ->setAttendanceQualifyTitle($qualTitle ?? '')
4820
                    ->setAttendanceQualifyMax($qualMax)
4821
                    ->setAttendanceWeight($weight)
4822
                    ->setLocked($locked)
4823
                ;
4824
4825
                // Link to course & creator if supported
4826
                if (method_exists($a, 'setParent')) {
4827
                    $a->setParent($courseEntity);
4828
                }
4829
                if (method_exists($a, 'setCreator')) {
4830
                    $a->setCreator(api_get_user_entity());
4831
                }
4832
                if (method_exists($a, 'addCourseLink')) {
4833
                    $a->addCourseLink($courseEntity, $sessionEntity);
4834
                }
4835
4836
                $em->persist($a);
4837
                $em->flush();
4838
4839
                // Map new IID back
4840
                $this->course->resources[RESOURCE_ATTENDANCE][$legacyId] ??= new stdClass();
4841
                $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int) $a->getIid();
4842
4843
                // Restore calendar entries (slots)
4844
                $calList = (array) ($att->attendance_calendar ?? []);
4845
                foreach ($calList as $c) {
4846
                    if (!\is_array($c)) {
4847
                        $c = (array) $c;
4848
                    }
4849
4850
                    // Date/time normalization with fallbacks
4851
                    $rawDt = (string) ($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? '');
4852
4853
                    try {
4854
                        $dt = '' !== $rawDt ? new DateTime($rawDt) : new DateTime('now', new DateTimeZone('UTC'));
4855
                    } catch (Throwable) {
4856
                        $dt = new DateTime('now', new DateTimeZone('UTC'));
4857
                    }
4858
4859
                    $done = (bool) ($c['done_attendance'] ?? $c['doneAttendance'] ?? false);
4860
                    $blocked = (bool) ($c['blocked'] ?? false);
4861
                    $duration = isset($c['duration']) ? (int) $c['duration'] : null;
4862
4863
                    $cal = (new CAttendanceCalendar())
4864
                        ->setAttendance($a)
4865
                        ->setDateTime($dt)
4866
                        ->setDoneAttendance($done)
4867
                        ->setBlocked($blocked)
4868
                        ->setDuration($duration)
4869
                    ;
4870
4871
                    $em->persist($cal);
4872
                    $em->flush();
4873
4874
                    // Optionally attach a group to the calendar slot
4875
                    $groupId = (int) ($c['group_id'] ?? 0);
4876
                    if ($groupId > 0) {
4877
                        try {
4878
                            $repo = $em->getRepository(CAttendanceCalendarRelGroup::class);
4879
                            if (method_exists($repo, 'addGroupToCalendar')) {
4880
                                $repo->addGroupToCalendar((int) $cal->getIid(), $groupId);
4881
                            }
4882
                        } catch (Throwable $e) {
4883
                            $this->debug && error_log('COURSE_DEBUG: restore_attendance: calendar group link skipped: '.$e->getMessage());
4884
                        }
4885
                    }
4886
                }
4887
4888
                // Flush at the end for this attendance
4889
                $em->flush();
4890
                $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int) $a->getIid().' (cal='.\count($calList).')');
4891
            } catch (Throwable $e) {
4892
                error_log('COURSE_DEBUG: restore_attendance: failed: '.$e->getMessage());
4893
4894
                continue;
4895
            }
4896
        }
4897
    }
4898
4899
    /**
4900
     * Restore Student Publications (works) from backup selection.
4901
     * - Honors file policy: FILE_SKIP (1), FILE_RENAME (2), FILE_OVERWRITE (3)
4902
     * - Creates a fresh ResourceNode for new items to avoid unique key collisions
4903
     * - Keeps existing behavior: HTML rewriting, optional calendar event, destination_id mapping
4904
     * - NO entity manager reopen helper (we avoid violations proactively).
4905
     */
4906
    public function restore_works(int $sessionId = 0): void
4907
    {
4908
        if (!$this->course->has_resources(RESOURCE_WORK)) {
4909
            return;
4910
        }
4911
4912
        $em = Database::getManager();
4913
4914
        /** @var CourseEntity $courseEntity */
4915
        $courseEntity = api_get_course_entity($this->destination_course_id);
4916
4917
        /** @var SessionEntity|null $sessionEntity */
4918
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
4919
4920
        /** @var CStudentPublicationRepository $pubRepo */
4921
        $pubRepo = Container::getStudentPublicationRepository();
4922
4923
        // Same-name policy already mapped at controller/restorer level
4924
        $filePolicy = $this->file_option ?? (\defined('FILE_RENAME') ? FILE_RENAME : 2);
4925
4926
        $this->dlog('restore_works: begin', [
4927
            'count' => \count($this->course->resources[RESOURCE_WORK] ?? []),
4928
            'policy' => $filePolicy,
4929
        ]);
4930
4931
        // Helper: generate a unique title within (course, session) scope
4932
        $makeUniqueTitle = function (string $base) use ($pubRepo, $courseEntity, $sessionEntity): string {
4933
            $t = '' !== $base ? $base : 'Work';
4934
            $n = 0;
4935
            $title = $t;
4936
            while (true) {
4937
                $qb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder');
4938
                $exists = $qb
4939
                    ->andWhere('resource.publicationParent IS NULL')
4940
                    ->andWhere('resource.active IN (0,1)')
4941
                    ->setMaxResults(1)
4942
                    ->getQuery()
4943
                    ->getOneOrNullResult()
4944
                ;
4945
                if (!$exists) {
4946
                    return $title;
4947
                }
4948
                $n++;
4949
                $title = $t.' ('.$n.')';
4950
            }
4951
        };
4952
4953
        // Helper: create a fresh ResourceNode for the publication
4954
        $createResourceNode = function (string $title) use ($em, $courseEntity, $sessionEntity) {
4955
            $nodeClass = ResourceNode::class;
4956
            $node = new $nodeClass();
4957
            if (method_exists($node, 'setTitle')) {
4958
                $node->setTitle($title);
4959
            }
4960
            if (method_exists($node, 'setCourse')) {
4961
                $node->setCourse($courseEntity);
4962
            }
4963
            if (method_exists($node, 'addCourseLink')) {
4964
                $node->addCourseLink($courseEntity, $sessionEntity);
4965
            }
4966
            if (method_exists($node, 'setResourceType')) {
4967
                $node->setResourceType('student_publication');
4968
            }
4969
            $em->persist($node);
4970
4971
            // flush is deferred to the publication flush
4972
            return $node;
4973
        };
4974
4975
        foreach ($this->course->resources[RESOURCE_WORK] as $legacyId => $obj) {
4976
            try {
4977
                $p = (array) ($obj->params ?? []);
4978
4979
                $title = trim((string) ($p['title'] ?? 'Work'));
4980
                if ('' === $title) {
4981
                    $title = 'Work';
4982
                }
4983
                $originalTitle = $title;
4984
4985
                $description = (string) ($p['description'] ?? '');
4986
                // HTML rewrite (assignment description)
4987
                $description = $this->rewriteHtmlForCourse($description, (int) $sessionId, '[work.description]');
4988
4989
                $enableQualification = (bool) ($p['enable_qualification'] ?? false);
4990
                $addToCalendar = 1 === (int) ($p['add_to_calendar'] ?? 0);
4991
4992
                $expiresOn = !empty($p['expires_on']) ? new DateTime($p['expires_on']) : null;
4993
                $endsOn = !empty($p['ends_on']) ? new DateTime($p['ends_on']) : null;
4994
4995
                $weight = isset($p['weight']) ? (float) $p['weight'] : 0.0;
4996
                $qualification = isset($p['qualification']) ? (float) $p['qualification'] : 0.0;
4997
                $allowText = (int) ($p['allow_text_assignment'] ?? 0);
4998
                $defaultVisibility = (bool) ($p['default_visibility'] ?? 0);
4999
                $studentMayDelete = (bool) ($p['student_delete_own_publication'] ?? 0);
5000
                $extensions = isset($p['extensions']) ? (string) $p['extensions'] : null;
5001
                $groupCategoryWorkId = (int) ($p['group_category_work_id'] ?? 0);
5002
                $postGroupId = (int) ($p['post_group_id'] ?? 0);
5003
5004
                // Check for existing root folder with same title
5005
                $existingQb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder');
5006
                $existing = $existingQb
5007
                    ->andWhere('resource.publicationParent IS NULL')
5008
                    ->andWhere('resource.active IN (0,1)')
5009
                    ->setMaxResults(1)
5010
                    ->getQuery()
5011
                    ->getOneOrNullResult()
5012
                ;
5013
5014
                // Apply same-name policy proactively (avoid unique violations)
5015
                if ($existing) {
5016
                    if ($filePolicy === (\defined('FILE_SKIP') ? FILE_SKIP : 1)) {
5017
                        $this->dlog('WORK: skip existing title', ['title' => $title, 'src_id' => $legacyId]);
5018
                        $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5019
                        $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid();
5020
5021
                        continue;
5022
                    }
5023
                    if ($filePolicy === (\defined('FILE_RENAME') ? FILE_RENAME : 2)) {
5024
                        $title = $makeUniqueTitle($title);
5025
                        $existing = null; // force a new one
5026
                    }
5027
                // FILE_OVERWRITE: keep $existing and update below
5028
                } else {
5029
                    // No existing — still ensure uniqueness to avoid slug/node collisions
5030
                    $title = $makeUniqueTitle($title);
5031
                }
5032
5033
                if (!$existing) {
5034
                    // Create NEW publication (folder) + NEW resource node
5035
                    $pub = (new CStudentPublication())
5036
                        ->setTitle($title)
5037
                        ->setDescription($description)  // already rewritten
5038
                        ->setFiletype('folder')
5039
                        ->setContainsFile(0)
5040
                        ->setWeight($weight)
5041
                        ->setQualification($qualification)
5042
                        ->setAllowTextAssignment($allowText)
5043
                        ->setDefaultVisibility($defaultVisibility)
5044
                        ->setStudentDeleteOwnPublication($studentMayDelete)
5045
                        ->setExtensions($extensions)
5046
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
5047
                        ->setPostGroupId($postGroupId)
5048
                    ;
5049
5050
                    if (method_exists($pub, 'setParent')) {
5051
                        $pub->setParent($courseEntity);
5052
                    }
5053
                    if (method_exists($pub, 'setCreator')) {
5054
                        $pub->setCreator(api_get_user_entity());
5055
                    }
5056
                    if (method_exists($pub, 'addCourseLink')) {
5057
                        $pub->addCourseLink($courseEntity, $sessionEntity);
5058
                    }
5059
                    if (method_exists($pub, 'setResourceNode')) {
5060
                        $pub->setResourceNode($createResourceNode($title));
5061
                    }
5062
5063
                    $em->persist($pub);
5064
5065
                    try {
5066
                        $em->flush();
5067
                    } catch (UniqueConstraintViolationException $e) {
5068
                        // As a last resort, rename once and retry quickly (no EM reopen)
5069
                        $this->dlog('WORK: unique violation on create, retry once with renamed title', [
5070
                            'src_id' => $legacyId,
5071
                            'err' => $e->getMessage(),
5072
                        ]);
5073
                        $newTitle = $makeUniqueTitle($title);
5074
                        if (method_exists($pub, 'setTitle')) {
5075
                            $pub->setTitle($newTitle);
5076
                        }
5077
                        if (method_exists($pub, 'setResourceNode')) {
5078
                            $pub->setResourceNode($createResourceNode($newTitle));
5079
                        }
5080
                        $em->persist($pub);
5081
                        $em->flush();
5082
                    }
5083
5084
                    // Create Assignment row
5085
                    $assignment = (new CStudentPublicationAssignment())
5086
                        ->setPublication($pub)
5087
                        ->setEnableQualification($enableQualification || $qualification > 0)
5088
                    ;
5089
                    if ($expiresOn) {
5090
                        $assignment->setExpiresOn($expiresOn);
5091
                    }
5092
                    if ($endsOn) {
5093
                        $assignment->setEndsOn($endsOn);
5094
                    }
5095
5096
                    $em->persist($assignment);
5097
                    $em->flush();
5098
5099
                    // Optional calendar entry
5100
                    if ($addToCalendar) {
5101
                        $eventTitle = \sprintf(get_lang('Handing over of task %s'), $pub->getTitle());
5102
5103
                        $publicationUrl = null;
5104
                        $uuid = $pub->getResourceNode()?->getUuid();
5105
                        if ($uuid) {
5106
                            if (property_exists($this, 'router') && $this->router instanceof RouterInterface) {
5107
                                try {
5108
                                    $publicationUrl = $this->router->generate(
5109
                                        'student_publication_view',
5110
                                        ['uuid' => (string) $uuid],
5111
                                        UrlGeneratorInterface::ABSOLUTE_PATH
5112
                                    );
5113
                                } catch (Throwable) {
5114
                                    $publicationUrl = '/r/student_publication/'.$uuid;
5115
                                }
5116
                            } else {
5117
                                $publicationUrl = '/r/student_publication/'.$uuid;
5118
                            }
5119
                        }
5120
5121
                        $contentBlock = \sprintf(
5122
                            '<div>%s</div> %s',
5123
                            $publicationUrl
5124
                                ? \sprintf('<a href="%s">%s</a>', $publicationUrl, htmlspecialchars($pub->getTitle(), ENT_QUOTES))
5125
                                : htmlspecialchars($pub->getTitle(), ENT_QUOTES),
5126
                            $pub->getDescription()
5127
                        );
5128
                        $contentBlock = $this->rewriteHtmlForCourse($contentBlock, (int) $sessionId, '[work.calendar]');
5129
5130
                        $start = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC'));
5131
                        $end = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC'));
5132
5133
                        $color = CCalendarEvent::COLOR_STUDENT_PUBLICATION;
5134
                        if ($colors = api_get_setting('agenda.agenda_colors')) {
5135
                            if (!empty($colors['student_publication'])) {
5136
                                $color = $colors['student_publication'];
5137
                            }
5138
                        }
5139
5140
                        $event = (new CCalendarEvent())
5141
                            ->setTitle($eventTitle)
5142
                            ->setContent($contentBlock)
5143
                            ->setParent($courseEntity)
5144
                            ->setCreator($pub->getCreator())
5145
                            ->addLink(clone $pub->getFirstResourceLink())
5146
                            ->setStartDate($start)
5147
                            ->setEndDate($end)
5148
                            ->setColor($color)
5149
                        ;
5150
5151
                        $em->persist($event);
5152
                        $em->flush();
5153
5154
                        $assignment->setEventCalendarId((int) $event->getIid());
5155
                        $em->flush();
5156
                    }
5157
5158
                    // Map destination for LP path resolution
5159
                    $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5160
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $pub->getIid();
5161
5162
                    $this->dlog('restore_works: created', [
5163
                        'src_id' => (int) $legacyId,
5164
                        'dst_iid' => (int) $pub->getIid(),
5165
                        'title' => $pub->getTitle(),
5166
                    ]);
5167
                } else {
5168
                    // FILE_OVERWRITE: update existing
5169
                    $existing
5170
                        ->setDescription($this->rewriteHtmlForCourse((string) $description, (int) $sessionId, '[work.description.overwrite]'))
5171
                        ->setWeight($weight)
5172
                        ->setQualification($qualification)
5173
                        ->setAllowTextAssignment($allowText)
5174
                        ->setDefaultVisibility($defaultVisibility)
5175
                        ->setStudentDeleteOwnPublication($studentMayDelete)
5176
                        ->setExtensions($extensions)
5177
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
5178
                        ->setPostGroupId($postGroupId)
5179
                    ;
5180
5181
                    // Ensure it has a ResourceNode
5182
                    if (method_exists($existing, 'getResourceNode') && method_exists($existing, 'setResourceNode')) {
5183
                        if (!$existing->getResourceNode()) {
5184
                            $existing->setResourceNode($createResourceNode($existing->getTitle() ?: $originalTitle));
5185
                        }
5186
                    }
5187
5188
                    $em->persist($existing);
5189
                    $em->flush();
5190
5191
                    // Assignment row
5192
                    $assignment = $existing->getAssignment();
5193
                    if (!$assignment) {
5194
                        $assignment = new CStudentPublicationAssignment();
5195
                        $assignment->setPublication($existing);
5196
                        $em->persist($assignment);
5197
                    }
5198
                    $assignment->setEnableQualification($enableQualification || $qualification > 0);
5199
                    $assignment->setExpiresOn($expiresOn);
5200
                    $assignment->setEndsOn($endsOn);
5201
                    if (!$addToCalendar) {
5202
                        $assignment->setEventCalendarId(0);
5203
                    }
5204
                    $em->flush();
5205
5206
                    $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5207
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid();
5208
5209
                    $this->dlog('restore_works: overwritten existing', [
5210
                        'src_id' => (int) $legacyId,
5211
                        'dst_iid' => (int) $existing->getIid(),
5212
                        'title' => $existing->getTitle(),
5213
                    ]);
5214
                }
5215
            } catch (Throwable $e) {
5216
                $this->dlog('restore_works: failed', [
5217
                    'src_id' => (int) $legacyId,
5218
                    'err' => $e->getMessage(),
5219
                ]);
5220
5221
                // Do NOT try to reopen EM here (as requested) — just continue gracefully
5222
                continue;
5223
            }
5224
        }
5225
5226
        $this->dlog('restore_works: end');
5227
    }
5228
5229
    /**
5230
     * Restore the Gradebook structure (categories, evaluations, links).
5231
     * Overwrites destination gradebook for the course/session.
5232
     */
5233
    public function restore_gradebook(int $sessionId = 0): void
5234
    {
5235
        // Only meaningful with OVERWRITE semantics (skip/rename make little sense here)
5236
        if (\in_array($this->file_option, [FILE_SKIP, FILE_RENAME], true)) {
5237
            return;
5238
        }
5239
5240
        if (!$this->course->has_resources(RESOURCE_GRADEBOOK)) {
5241
            $this->dlog('restore_gradebook: no gradebook resources');
5242
5243
            return;
5244
        }
5245
5246
        /** @var EntityManagerInterface $em */
5247
        $em = Database::getManager();
5248
5249
        /** @var Course $courseEntity */
5250
        $courseEntity = api_get_course_entity($this->destination_course_id);
5251
5252
        /** @var SessionEntity|null $sessionEntity */
5253
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
5254
5255
        /** @var User $currentUser */
5256
        $currentUser = api_get_user_entity();
5257
5258
        $catRepo = $em->getRepository(GradebookCategory::class);
5259
5260
        // Clean destination categories when overwriting
5261
        try {
5262
            $existingCats = $catRepo->findBy([
5263
                'course' => $courseEntity,
5264
                'session' => $sessionEntity,
5265
            ]);
5266
            foreach ($existingCats as $cat) {
5267
                $em->remove($cat);
5268
            }
5269
            $em->flush();
5270
            $this->dlog('restore_gradebook: destination cleaned', ['removed' => \count($existingCats)]);
5271
        } catch (Throwable $e) {
5272
            $this->dlog('restore_gradebook: clean failed (continuing)', ['error' => $e->getMessage()]);
5273
        }
5274
5275
        $oldIdToNewCat = [];
5276
5277
        // First pass: create categories
5278
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5279
            $categories = (array) ($gbItem->categories ?? []);
5280
            foreach ($categories as $rawCat) {
5281
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5282
5283
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5284
                $title = (string) ($c['title'] ?? 'Category');
5285
                $desc = (string) ($c['description'] ?? '');
5286
                $weight = (float) ($c['weight'] ?? 0.0);
5287
                $visible = (bool) ($c['visible'] ?? true);
5288
                $locked = (int) ($c['locked'] ?? 0);
5289
5290
                // Rewrite HTML in category description
5291
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[gradebook.category]');
5292
5293
                $new = (new GradebookCategory())
5294
                    ->setCourse($courseEntity)
5295
                    ->setSession($sessionEntity)
5296
                    ->setUser($currentUser)
5297
                    ->setTitle($title)
5298
                    ->setDescription($desc)
5299
                    ->setWeight($weight)
5300
                    ->setVisible($visible)
5301
                    ->setLocked($locked)
5302
                ;
5303
5304
                // Optional flags (mirror legacy fields)
5305
                if (isset($c['generate_certificates'])) {
5306
                    $new->setGenerateCertificates((bool) $c['generate_certificates']);
5307
                }
5308
                if (isset($c['generateCertificates'])) {
5309
                    $new->setGenerateCertificates((bool) $c['generateCertificates']);
5310
                }
5311
                if (isset($c['certificate_validity_period'])) {
5312
                    $new->setCertificateValidityPeriod((int) $c['certificate_validity_period']);
5313
                }
5314
                if (isset($c['certificateValidityPeriod'])) {
5315
                    $new->setCertificateValidityPeriod((int) $c['certificateValidityPeriod']);
5316
                }
5317
                if (isset($c['is_requirement'])) {
5318
                    $new->setIsRequirement((bool) $c['is_requirement']);
5319
                }
5320
                if (isset($c['isRequirement'])) {
5321
                    $new->setIsRequirement((bool) $c['isRequirement']);
5322
                }
5323
                if (isset($c['default_lowest_eval_exclude'])) {
5324
                    $new->setDefaultLowestEvalExclude((bool) $c['default_lowest_eval_exclude']);
5325
                }
5326
                if (isset($c['defaultLowestEvalExclude'])) {
5327
                    $new->setDefaultLowestEvalExclude((bool) $c['defaultLowestEvalExclude']);
5328
                }
5329
                if (\array_key_exists('minimum_to_validate', $c)) {
5330
                    $new->setMinimumToValidate((int) $c['minimum_to_validate']);
5331
                }
5332
                if (\array_key_exists('minimumToValidate', $c)) {
5333
                    $new->setMinimumToValidate((int) $c['minimumToValidate']);
5334
                }
5335
                if (\array_key_exists('gradebooks_to_validate_in_dependence', $c)) {
5336
                    $new->setGradeBooksToValidateInDependence((int) $c['gradebooks_to_validate_in_dependence']);
5337
                }
5338
                if (\array_key_exists('gradeBooksToValidateInDependence', $c)) {
5339
                    $new->setGradeBooksToValidateInDependence((int) $c['gradeBooksToValidateInDependence']);
5340
                }
5341
                if (\array_key_exists('allow_skills_by_subcategory', $c)) {
5342
                    $new->setAllowSkillsBySubcategory((int) $c['allow_skills_by_subcategory']);
5343
                }
5344
                if (\array_key_exists('allowSkillsBySubcategory', $c)) {
5345
                    $new->setAllowSkillsBySubcategory((int) $c['allowSkillsBySubcategory']);
5346
                }
5347
                if (!empty($c['grade_model_id'])) {
5348
                    $gm = $em->find(GradeModel::class, (int) $c['grade_model_id']);
5349
                    if ($gm) {
5350
                        $new->setGradeModel($gm);
5351
                    }
5352
                }
5353
5354
                $em->persist($new);
5355
                $em->flush();
5356
5357
                if ($oldId > 0) {
5358
                    $oldIdToNewCat[$oldId] = $new;
5359
                }
5360
            }
5361
        }
5362
5363
        // Second pass: wire category parents
5364
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5365
            $categories = (array) ($gbItem->categories ?? []);
5366
            foreach ($categories as $rawCat) {
5367
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5368
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5369
                $parentOld = (int) ($c['parent_id'] ?? $c['parentId'] ?? 0);
5370
                if ($oldId > 0 && isset($oldIdToNewCat[$oldId]) && $parentOld > 0 && isset($oldIdToNewCat[$parentOld])) {
5371
                    $cat = $oldIdToNewCat[$oldId];
5372
                    $cat->setParent($oldIdToNewCat[$parentOld]);
5373
                    $em->persist($cat);
5374
                }
5375
            }
5376
        }
5377
        $em->flush();
5378
5379
        // Evaluations and Links per category
5380
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5381
            $categories = (array) ($gbItem->categories ?? []);
5382
            foreach ($categories as $rawCat) {
5383
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5384
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5385
                if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) {
5386
                    continue;
5387
                }
5388
5389
                $dstCat = $oldIdToNewCat[$oldId];
5390
5391
                // Evaluations (rewrite description HTML)
5392
                foreach ((array) ($c['evaluations'] ?? []) as $rawEval) {
5393
                    $e = \is_array($rawEval) ? $rawEval : (array) $rawEval;
5394
5395
                    $evalDesc = (string) ($e['description'] ?? '');
5396
                    $evalDesc = $this->rewriteHtmlForCourse($evalDesc, (int) $sessionId, '[gradebook.evaluation]');
5397
5398
                    $eval = (new GradebookEvaluation())
5399
                        ->setCourse($courseEntity)
5400
                        ->setCategory($dstCat)
5401
                        ->setTitle((string) ($e['title'] ?? 'Evaluation'))
5402
                        ->setDescription($evalDesc)
5403
                        ->setWeight((float) ($e['weight'] ?? 0.0))
5404
                        ->setMax((float) ($e['max'] ?? 100.0))
5405
                        ->setType((string) ($e['type'] ?? 'manual'))
5406
                        ->setVisible((int) ($e['visible'] ?? 1))
5407
                        ->setLocked((int) ($e['locked'] ?? 0))
5408
                    ;
5409
5410
                    // Optional statistics fields
5411
                    if (isset($e['best_score'])) {
5412
                        $eval->setBestScore((float) $e['best_score']);
5413
                    }
5414
                    if (isset($e['average_score'])) {
5415
                        $eval->setAverageScore((float) $e['average_score']);
5416
                    }
5417
                    if (isset($e['score_weight'])) {
5418
                        $eval->setScoreWeight((float) $e['score_weight']);
5419
                    }
5420
                    if (isset($e['min_score'])) {
5421
                        $eval->setMinScore((float) $e['min_score']);
5422
                    }
5423
5424
                    $em->persist($eval);
5425
                }
5426
5427
                // Links to course tools (resolve destination IID for each)
5428
                foreach ((array) ($c['links'] ?? []) as $rawLink) {
5429
                    $l = \is_array($rawLink) ? $rawLink : (array) $rawLink;
5430
5431
                    $linkType = (int) ($l['type'] ?? $l['link_type'] ?? 0);
5432
                    $legacyRef = (int) ($l['ref_id'] ?? $l['refId'] ?? 0);
5433
                    if ($linkType <= 0 || $legacyRef <= 0) {
5434
                        $this->dlog('restore_gradebook: skipping link (missing type/ref)', $l);
5435
5436
                        continue;
5437
                    }
5438
5439
                    // Map link type → resource bucket, then resolve legacyId → newId
5440
                    $resourceType = $this->gb_guessResourceTypeByLinkType($linkType);
5441
                    $newRefId = $this->gb_resolveDestinationId($resourceType, $legacyRef);
5442
                    if ($newRefId <= 0) {
5443
                        $this->dlog('restore_gradebook: skipping link (no destination id)', ['type' => $linkType, 'legacyRef' => $legacyRef]);
5444
5445
                        continue;
5446
                    }
5447
5448
                    $link = (new GradebookLink())
5449
                        ->setCourse($courseEntity)
5450
                        ->setCategory($dstCat)
5451
                        ->setType($linkType)
5452
                        ->setRefId($newRefId)
5453
                        ->setWeight((float) ($l['weight'] ?? 0.0))
5454
                        ->setVisible((int) ($l['visible'] ?? 1))
5455
                        ->setLocked((int) ($l['locked'] ?? 0))
5456
                    ;
5457
5458
                    // Optional statistics fields
5459
                    if (isset($l['best_score'])) {
5460
                        $link->setBestScore((float) $l['best_score']);
5461
                    }
5462
                    if (isset($l['average_score'])) {
5463
                        $link->setAverageScore((float) $l['average_score']);
5464
                    }
5465
                    if (isset($l['score_weight'])) {
5466
                        $link->setScoreWeight((float) $l['score_weight']);
5467
                    }
5468
                    if (isset($l['min_score'])) {
5469
                        $link->setMinScore((float) $l['min_score']);
5470
                    }
5471
5472
                    $em->persist($link);
5473
                }
5474
5475
                $em->flush();
5476
            }
5477
        }
5478
5479
        $this->dlog('restore_gradebook: done');
5480
    }
5481
5482
    /**
5483
     * Restore course assets (not included in documents).
5484
     */
5485
    public function restore_assets(): void
5486
    {
5487
        if ($this->course->has_resources(RESOURCE_ASSET)) {
5488
            $resources = $this->course->resources;
5489
            $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/';
5490
5491
            foreach ($resources[RESOURCE_ASSET] as $asset) {
5492
                if (is_file($this->course->backup_path.'/'.$asset->path)
5493
                    && is_readable($this->course->backup_path.'/'.$asset->path)
5494
                    && is_dir(\dirname($path.$asset->path))
5495
                    && is_writable(\dirname($path.$asset->path))
5496
                ) {
5497
                    switch ($this->file_option) {
5498
                        case FILE_SKIP:
5499
                            break;
5500
5501
                        case FILE_OVERWRITE:
5502
                            copy(
5503
                                $this->course->backup_path.'/'.$asset->path,
5504
                                $path.$asset->path
5505
                            );
5506
5507
                            break;
5508
                    }
5509
                }
5510
            }
5511
        }
5512
    }
5513
5514
    /**
5515
     * Get all resources from snapshot or live course object.
5516
     *
5517
     * @return array<string,array>
5518
     */
5519
    public function getAllResources(): array
5520
    {
5521
        // Prefer the previously captured snapshot if present; otherwise fall back to current course->resources
5522
        return !empty($this->resources_all_snapshot)
5523
            ? $this->resources_all_snapshot
5524
            : (array) ($this->course->resources ?? []);
5525
    }
5526
5527
    /**
5528
     * Back-fill empty dependency bags from the snapshot into $this->course->resources.
5529
     */
5530
    private function ensureDepsBagsFromSnapshot(): void
5531
    {
5532
        // Read the authoritative set of resources (snapshot or live)
5533
        $all = $this->getAllResources();
5534
5535
        // Reference the course resources by reference to update in place
5536
        $c = &$this->course->resources;
5537
5538
        // Ensure these resource bags exist; if missing/empty, copy them from snapshot
5539
        foreach (['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'] as $k) {
5540
            $cur = $c[$k] ?? [];
5541
            if ((!\is_array($cur) || 0 === \count($cur)) && !empty($all[$k]) && \is_array($all[$k])) {
5542
                // Back-fill from snapshot to keep dependencies consistent
5543
                $c[$k] = $all[$k];
5544
            }
5545
        }
5546
    }
5547
5548
    /**
5549
     * Rewrite HTML content so legacy course URLs point to destination course documents.
5550
     *
5551
     * Returns the (possibly) rewritten HTML.
5552
     */
5553
    private function rewriteHtmlForCourse(string $html, int $sessionId, string $dbgTag = ''): string
5554
    {
5555
        // Nothing to do if the HTML is empty
5556
        if ('' === $html) {
5557
            return '';
5558
        }
5559
5560
        // Resolve context entities (course/session/group) and repositories
5561
        $course = api_get_course_entity($this->destination_course_id);
5562
        $session = api_get_session_entity((int) $sessionId);
5563
        $group = api_get_group_entity();
5564
        $docRepo = Container::getDocumentRepository();
5565
5566
        // Determine course directory and source root (when importing from a ZIP/package)
5567
        $courseDir = (string) ($this->course->info['path'] ?? '');
5568
        $srcRoot = rtrim((string) ($this->course->backup_path ?? ''), '/');
5569
5570
        // Cache of created folder IIDs per course dir to avoid duplicate folder creation
5571
        if (!isset($this->htmlFoldersByCourseDir[$courseDir])) {
5572
            $this->htmlFoldersByCourseDir[$courseDir] = [];
5573
        }
5574
        $folders = &$this->htmlFoldersByCourseDir[$courseDir];
5575
5576
        // Small debug helper bound to the current dbgTag
5577
        $DBG = function (string $tag, array $ctx = []) use ($dbgTag): void {
5578
            $this->dlog('HTMLRW'.$dbgTag.': '.$tag, $ctx);
5579
        };
5580
5581
        // Ensure a folder chain exists under /document and return parent IID (0 means root)
5582
        $ensureFolder = function (string $relPath) use (&$folders, $course, $session, $DBG) {
5583
            // Ignore empty/root markers
5584
            if ('/' === $relPath || '/document' === $relPath) {
5585
                return 0;
5586
            }
5587
5588
            // Reuse cached IID if we already created/resolved this path
5589
            if (!empty($folders[$relPath])) {
5590
                return (int) $folders[$relPath];
5591
            }
5592
5593
            try {
5594
                // Create the folder via DocumentManager; parent is resolved by the path
5595
                $entity = DocumentManager::addDocument(
5596
                    ['real_id' => $course->getId(), 'code' => method_exists($course, 'getCode') ? $course->getCode() : null],
5597
                    $relPath,
5598
                    'folder',
5599
                    0,
5600
                    basename($relPath),
5601
                    null,
5602
                    0,
5603
                    null,
5604
                    0,
5605
                    (int) ($session?->getId() ?? 0)
5606
                );
5607
5608
                // Cache the created IID if available
5609
                $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
5610
                if ($iid > 0) {
5611
                    $folders[$relPath] = $iid;
5612
                }
5613
5614
                return $iid;
5615
            } catch (Throwable $e) {
5616
                // Do not interrupt restore flow if folder creation fails
5617
                $DBG('ensureFolder.error', ['relPath' => $relPath, 'err' => $e->getMessage()]);
5618
5619
                return 0;
5620
            }
5621
        };
5622
5623
        // Only rewrite when we are importing from a package (ZIP) with a known source root
5624
        if ('' !== $srcRoot) {
5625
            try {
5626
                // Build a URL map for all legacy references found in the HTML
5627
                $mapDoc = ChamiloHelper::buildUrlMapForHtmlFromPackage(
5628
                    $html,
5629
                    $courseDir,
5630
                    $srcRoot,
5631
                    $folders,
5632
                    $ensureFolder,
5633
                    $docRepo,
5634
                    $course,
5635
                    $session,
5636
                    $group,
5637
                    (int) $sessionId,
5638
                    (int) $this->file_option,
5639
                    $DBG
5640
                );
5641
5642
                // Rewrite the HTML using both exact (byRel) and basename (byBase) maps
5643
                $rr = ChamiloHelper::rewriteLegacyCourseUrlsWithMap(
5644
                    $html,
5645
                    $courseDir,
5646
                    $mapDoc['byRel'] ?? [],
5647
                    $mapDoc['byBase'] ?? []
5648
                );
5649
5650
                // Log replacement stats for troubleshooting
5651
                $DBG('zip.rewrite', ['replaced' => $rr['replaced'] ?? 0, 'misses' => $rr['misses'] ?? 0]);
5652
5653
                // Return rewritten HTML when available; otherwise the original
5654
                return (string) ($rr['html'] ?? $html);
5655
            } catch (Throwable $e) {
5656
                // Fall back to original HTML if anything fails during mapping/rewrite
5657
                $DBG('zip.error', ['err' => $e->getMessage()]);
5658
5659
                return $html;
5660
            }
5661
        }
5662
5663
        // If no package source root, return the original HTML unchanged
5664
        return $html;
5665
    }
5666
5667
    /**
5668
     * Centralized logger controlled by $this->debug.
5669
     */
5670
    private function dlog(string $message, array $context = []): void
5671
    {
5672
        if (!$this->debug) {
5673
            return;
5674
        }
5675
        $ctx = '';
5676
        if (!empty($context)) {
5677
            try {
5678
                $ctx = ' '.json_encode(
5679
                        $context,
5680
                        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
5681
                    );
5682
            } catch (Throwable $e) {
5683
                $ctx = ' [context_json_failed: '.$e->getMessage().']';
5684
            }
5685
        }
5686
        error_log('COURSE_DEBUG: '.$message.$ctx);
5687
    }
5688
5689
    /**
5690
     * Public setter for the debug flag.
5691
     */
5692
    public function setDebug(?bool $on = true): void
5693
    {
5694
        $this->debug = (bool) $on;
5695
        $this->dlog('Debug flag changed', ['debug' => $this->debug]);
5696
    }
5697
5698
    /**
5699
     * Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored).
5700
     */
5701
    private function gb_resolveDestinationId(?int $type, int $legacyId): int
5702
    {
5703
        if (null === $type) {
5704
            return 0;
5705
        }
5706
        if (!$this->course->has_resources($type)) {
5707
            return 0;
5708
        }
5709
        $bucket = $this->course->resources[$type] ?? [];
5710
        if (!isset($bucket[$legacyId])) {
5711
            return 0;
5712
        }
5713
        $res = $bucket[$legacyId];
5714
        $destId = (int) ($res->destination_id ?? 0);
5715
5716
        return $destId > 0 ? $destId : 0;
5717
    }
5718
5719
    /**
5720
     * Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources.
5721
     */
5722
    private function gb_guessResourceTypeByLinkType(int $linkType): ?int
5723
    {
5724
        return match ($linkType) {
5725
            LINK_EXERCISE => RESOURCE_QUIZ,
5726
            LINK_STUDENTPUBLICATION => RESOURCE_WORK,
5727
            LINK_LEARNPATH => RESOURCE_LEARNPATH,
5728
            LINK_FORUM_THREAD => RESOURCE_FORUMTOPIC,
5729
            LINK_ATTENDANCE => RESOURCE_ATTENDANCE,
5730
            LINK_SURVEY => RESOURCE_SURVEY,
5731
            LINK_HOTPOTATOES => RESOURCE_QUIZ,
5732
            default => null,
5733
        };
5734
    }
5735
5736
    /**
5737
     * Add this setter to forward the full resources snapshot from the controller.
5738
     */
5739
    public function setResourcesAllSnapshot(array $snapshot): void
5740
    {
5741
        // Keep a private property like $this->resources_all_snapshot
5742
        // (declare it if you don't have it: private array $resources_all_snapshot = [];)
5743
        $this->resources_all_snapshot = $snapshot;
5744
        $this->dlog('Restorer: all-resources snapshot injected', [
5745
            'keys' => array_keys($snapshot),
5746
        ]);
5747
    }
5748
5749
    /**
5750
     * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP.
5751
     * Returns absolute path to the temp ZIP or null on error.
5752
     */
5753
    private function zipScormFolder(string $folderAbs): ?string
5754
    {
5755
        $folderAbs = rtrim($folderAbs, '/');
5756
        $manifest = $folderAbs.'/imsmanifest.xml';
5757
        if (!is_file($manifest)) {
5758
            error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs");
5759
5760
            return null;
5761
        }
5762
5763
        $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip';
5764
5765
        try {
5766
            $zip = new ZipFile();
5767
            // Put folder contents at the ZIP root – important for SCORM imports
5768
            $zip->addDirRecursive($folderAbs, '');
5769
            $zip->saveAsFile($tmpZip);
5770
            $zip->close();
5771
        } catch (Throwable $e) {
5772
            error_log('SCORM ZIPPER: Failed to create temp zip: '.$e->getMessage());
5773
5774
            return null;
5775
        }
5776
5777
        if (!is_file($tmpZip) || 0 === filesize($tmpZip)) {
5778
            @unlink($tmpZip);
5779
            error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip");
5780
5781
            return null;
5782
        }
5783
5784
        return $tmpZip;
5785
    }
5786
5787
    /**
5788
     * Find a SCORM package for a given LP.
5789
     * It returns ['zip' => <abs path or null>, 'temp' => true if zip is temporary].
5790
     *
5791
     * Search order:
5792
     *  1) resources[SCORM] entries bound to this LP (zip or path).
5793
     *     - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly.
5794
     *  2) Heuristics: scan typical folders for *.zip
5795
     *  3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder.
5796
     */
5797
    private function findScormPackageForLp(int $srcLpId): array
5798
    {
5799
        $out = ['zip' => null, 'temp' => false];
5800
        $base = rtrim($this->course->backup_path, '/');
5801
5802
        // 1) Direct mapping from SCORM bucket
5803
        if (!empty($this->course->resources[RESOURCE_SCORM]) && \is_array($this->course->resources[RESOURCE_SCORM])) {
5804
            foreach ($this->course->resources[RESOURCE_SCORM] as $sc) {
5805
                $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0;
5806
                $dst = isset($sc->lp_id_dest) ? (int) $sc->lp_id_dest : 0;
5807
                $match = ($src && $src === $srcLpId);
5808
5809
                if (
5810
                    !$match
5811
                    && $dst
5812
                    && !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id)
5813
                ) {
5814
                    $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id);
5815
                }
5816
                if (!$match) {
5817
                    continue;
5818
                }
5819
5820
                $cands = [];
5821
                if (!empty($sc->zip)) {
5822
                    $cands[] = $base.'/'.ltrim((string) $sc->zip, '/');
5823
                }
5824
                if (!empty($sc->path)) {
5825
                    $cands[] = $base.'/'.ltrim((string) $sc->path, '/');
5826
                }
5827
5828
                foreach ($cands as $abs) {
5829
                    if (is_file($abs) && is_readable($abs)) {
5830
                        $out['zip'] = $abs;
5831
                        $out['temp'] = false;
5832
5833
                        return $out;
5834
                    }
5835
                    if (is_dir($abs) && is_readable($abs)) {
5836
                        $tmp = $this->zipScormFolder($abs);
5837
                        if ($tmp) {
5838
                            $out['zip'] = $tmp;
5839
                            $out['temp'] = true;
5840
5841
                            return $out;
5842
                        }
5843
                    }
5844
                }
5845
            }
5846
        }
5847
5848
        // 2) Heuristic: typical folders with *.zip
5849
        foreach (['/scorm', '/document/scorm', '/documents/scorm'] as $dir) {
5850
            $full = $base.$dir;
5851
            if (!is_dir($full)) {
5852
                continue;
5853
            }
5854
            $glob = glob($full.'/*.zip') ?: [];
5855
            if (!empty($glob)) {
5856
                $out['zip'] = $glob[0];
5857
                $out['temp'] = false;
5858
5859
                return $out;
5860
            }
5861
        }
5862
5863
        // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder
5864
        $riiFlags = FilesystemIterator::SKIP_DOTS;
5865
5866
        try {
5867
            $rii = new RecursiveIteratorIterator(
5868
                new RecursiveDirectoryIterator($base, $riiFlags),
5869
                RecursiveIteratorIterator::SELF_FIRST
5870
            );
5871
            foreach ($rii as $f) {
5872
                if ($f->isFile() && 'imsmanifest.xml' === strtolower($f->getFilename())) {
5873
                    $folder = $f->getPath();
5874
                    $tmp = $this->zipScormFolder($folder);
5875
                    if ($tmp) {
5876
                        $out['zip'] = $tmp;
5877
                        $out['temp'] = true;
5878
5879
                        return $out;
5880
                    }
5881
                }
5882
            }
5883
        } catch (Throwable $e) {
5884
            error_log('SCORM FINDER: Recursive scan failed: '.$e->getMessage());
5885
        }
5886
5887
        return $out;
5888
    }
5889
5890
    /**
5891
     * Check if a survey code is available.
5892
     *
5893
     * @param mixed $survey_code
5894
     *
5895
     * @return bool
5896
     */
5897
    public function is_survey_code_available($survey_code)
5898
    {
5899
        $survey_code = (string) $survey_code;
5900
        $surveyRepo = Container::getSurveyRepository();
5901
5902
        try {
5903
            // If a survey with this code exists, it's not available
5904
            $hit = $surveyRepo->findOneBy(['code' => $survey_code]);
5905
5906
            return $hit ? false : true;
5907
        } catch (Throwable $e) {
5908
            // Fallback to "available" on repository failure
5909
            $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage());
5910
5911
            return true;
5912
        }
5913
    }
5914
5915
    /**
5916
     * Resolve absolute filesystem path for an announcement attachment.
5917
     */
5918
    private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string
5919
    {
5920
        // Get the resource node linked to this attachment
5921
        $node = $att->getResourceNode();
5922
        if (!$node) {
5923
            return null; // No node, nothing to resolve
5924
        }
5925
5926
        // Get the first physical resource file
5927
        $file = $node->getFirstResourceFile();
5928
        if (!$file) {
5929
            return null; // No physical file bound
5930
        }
5931
5932
        /** @var ResourceNodeRepository $rnRepo */
5933
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
5934
5935
        // Relative path stored by the repository
5936
        $rel = $rnRepo->getFilename($file);
5937
        if (!$rel) {
5938
            return null; // Missing relative path
5939
        }
5940
5941
        // Compose absolute path inside the project upload base
5942
        $abs = $this->projectUploadBase().$rel;
5943
5944
        // Return only if readable to avoid runtime errors
5945
        return is_readable($abs) ? $abs : null;
5946
    }
5947
5948
    /**
5949
     * Compact dump of resources: keys, per-bag counts and one sample (trimmed).
5950
     */
5951
    private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void
5952
    {
5953
        try {
5954
            $resources = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
5955
5956
            $safe = function ($data): string {
5957
                try {
5958
                    return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]';
5959
                } catch (Throwable $e) {
5960
                    return '[json_exception: '.$e->getMessage().']';
5961
                }
5962
            };
5963
            $short = function ($v, int $max = 200) {
5964
                if (\is_string($v)) {
5965
                    $s = trim($v);
5966
5967
                    return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s;
5968
                }
5969
                if (is_numeric($v) || \is_bool($v) || null === $v) {
5970
                    return $v;
5971
                }
5972
5973
                return '['.\gettype($v).']';
5974
            };
5975
            $sample = function ($item) use ($short, $maxObjFields) {
5976
                $out = [
5977
                    'source_id' => null,
5978
                    'destination_id' => null,
5979
                    'type' => null,
5980
                    'has_obj' => false,
5981
                    'obj_fields' => [],
5982
                    'has_item_props' => false,
5983
                    'extra' => [],
5984
                ];
5985
                if (\is_object($item) || \is_array($item)) {
5986
                    $arr = (array) $item;
5987
                    $out['source_id'] = $arr['source_id'] ?? null;
5988
                    $out['destination_id'] = $arr['destination_id'] ?? null;
5989
                    $out['type'] = $arr['type'] ?? null;
5990
                    $out['has_item_props'] = !empty($arr['item_properties']);
5991
5992
                    $obj = $arr['obj'] ?? null;
5993
                    if (\is_object($obj) || \is_array($obj)) {
5994
                        $out['has_obj'] = true;
5995
                        $objArr = (array) $obj;
5996
                        $fields = [];
5997
                        $i = 0;
5998
                        foreach ($objArr as $k => $v) {
5999
                            if ($i++ >= $maxObjFields) {
6000
                                $fields['__notice'] = 'truncated';
6001
6002
                                break;
6003
                            }
6004
                            $fields[$k] = $short($v);
6005
                        }
6006
                        $out['obj_fields'] = $fields;
6007
                    }
6008
                    foreach (['path', 'title', 'comment'] as $k) {
6009
                        if (isset($arr[$k])) {
6010
                            $out['extra'][$k] = $short($arr[$k]);
6011
                        }
6012
                    }
6013
                } else {
6014
                    $out['extra']['_type'] = \gettype($item);
6015
                }
6016
6017
                return $out;
6018
            };
6019
6020
            $this->dlog('Resources overview', ['keys' => array_keys($resources)]);
6021
6022
            foreach ($resources as $bagName => $bag) {
6023
                if (!\is_array($bag)) {
6024
                    $this->dlog('Bag not an array, skipping', ['bag' => $bagName, 'type' => \gettype($bag)]);
6025
6026
                    continue;
6027
                }
6028
                $count = \count($bag);
6029
                $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]);
6030
6031
                if ($count > 0) {
6032
                    $firstKey = array_key_first($bag);
6033
                    $firstVal = $bag[$firstKey];
6034
                    $s = $sample($firstVal);
6035
                    $s['__first_key'] = $firstKey;
6036
                    $s['__class'] = \is_object($firstVal) ? $firstVal::class : \gettype($firstVal);
6037
                    $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]);
6038
                }
6039
6040
                if (null !== $focusBag && $focusBag === $bagName) {
6041
                    $preview = [];
6042
                    $i = 0;
6043
                    foreach ($bag as $k => $v) {
6044
                        if ($i++ >= 10) {
6045
                            $preview[] = ['__notice' => 'truncated-after-10-items'];
6046
6047
                            break;
6048
                        }
6049
                        $preview[] = ['key' => $k, 'sample' => $sample($v)];
6050
                    }
6051
                    $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]);
6052
                }
6053
            }
6054
        } catch (Throwable $e) {
6055
            $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]);
6056
        }
6057
    }
6058
6059
    /**
6060
     * Get absolute base path where ResourceFiles are stored in the project.
6061
     */
6062
    private function projectUploadBase(): string
6063
    {
6064
        /** @var KernelInterface $kernel */
6065
        $kernel = Container::$container->get('kernel');
6066
6067
        // Resource uploads live under var/upload/resource (Symfony project dir)
6068
        return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource';
6069
    }
6070
6071
    /**
6072
     * Resolve the absolute file path for a CDocument's first ResourceFile, if readable.
6073
     */
6074
    private function resourceFileAbsPathFromDocument(CDocument $doc): ?string
6075
    {
6076
        // Each CDocument references a ResourceNode; bail out if missing
6077
        $node = $doc->getResourceNode();
6078
        if (!$node) {
6079
            return null;
6080
        }
6081
6082
        // Use the first ResourceFile attached to the node
6083
        $file = $node->getFirstResourceFile();
6084
        if (!$file) {
6085
            return null;
6086
        }
6087
6088
        /** @var ResourceNodeRepository $rnRepo */
6089
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
6090
6091
        // Repository provides the relative path for the resource file
6092
        $rel = $rnRepo->getFilename($file);
6093
        if (!$rel) {
6094
            return null;
6095
        }
6096
6097
        // Compose absolute path and validate readability
6098
        $abs = $this->projectUploadBase().$rel;
6099
6100
        return is_readable($abs) ? $abs : null;
6101
    }
6102
6103
    /**
6104
     * Normalize forum keys so internal bags are always available.
6105
     */
6106
    private function normalizeForumKeys(): void
6107
    {
6108
        if (!\is_array($this->course->resources ?? null)) {
6109
            $this->course->resources = [];
6110
6111
            return;
6112
        }
6113
        $r = $this->course->resources;
6114
6115
        // Categories
6116
        if (!isset($r['Forum_Category']) && isset($r['forum_category'])) {
6117
            $r['Forum_Category'] = $r['forum_category'];
6118
        }
6119
6120
        // Forums
6121
        if (!isset($r['forum']) && isset($r['Forum'])) {
6122
            $r['forum'] = $r['Forum'];
6123
        }
6124
6125
        // Topics
6126
        if (!isset($r['thread']) && isset($r['forum_topic'])) {
6127
            $r['thread'] = $r['forum_topic'];
6128
        } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) {
6129
            $r['thread'] = $r['Forum_Thread'];
6130
        }
6131
6132
        // Posts
6133
        if (!isset($r['post']) && isset($r['forum_post'])) {
6134
            $r['post'] = $r['forum_post'];
6135
        } elseif (!isset($r['post']) && isset($r['Forum_Post'])) {
6136
            $r['post'] = $r['Forum_Post'];
6137
        }
6138
6139
        $this->course->resources = $r;
6140
        $this->dlog('Forum keys normalized', [
6141
            'has_Forum_Category' => isset($r['Forum_Category']),
6142
            'forum_count' => isset($r['forum']) && \is_array($r['forum']) ? \count($r['forum']) : 0,
6143
            'thread_count' => isset($r['thread']) && \is_array($r['thread']) ? \count($r['thread']) : 0,
6144
            'post_count' => isset($r['post']) && \is_array($r['post']) ? \count($r['post']) : 0,
6145
        ]);
6146
    }
6147
6148
    /**
6149
     * Reset Doctrine if the EntityManager is closed; otherwise clear it.
6150
     */
6151
    private function resetDoctrineIfClosed(): void
6152
    {
6153
        try {
6154
            // Get the current EntityManager
6155
            $em = Database::getManager();
6156
6157
            if (!$em->isOpen()) {
6158
                // If closed, reset the manager to recover from fatal transaction errors
6159
                $registry = Container::$container->get('doctrine');
6160
                $registry->resetManager();
6161
            } else {
6162
                // If open, just clear to free managed entities and avoid memory leaks
6163
                $em->clear();
6164
            }
6165
        } catch (Throwable $e) {
6166
            // Never break the flow due to maintenance logic
6167
            error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage());
6168
        }
6169
    }
6170
6171
    private function getCourseBackupsBase(): string
6172
    {
6173
        try {
6174
            if (method_exists(CourseArchiver::class, 'getBackupDir')) {
6175
                $dir = rtrim(CourseArchiver::getBackupDir(), '/');
6176
                if ($dir !== '') {
6177
                    return $dir;
6178
                }
6179
            }
6180
        } catch (\Throwable $e) {
6181
        }
6182
6183
        return rtrim(api_get_path(SYS_ARCHIVE_PATH), '/').'/course_backups';
6184
    }
6185
}
6186