CourseRestorer::restore_documents()   F
last analyzed

Complexity

Conditions 157
Paths 2441

Size

Total Lines 629
Code Lines 382

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 157
eloc 382
nc 2441
nop 3
dl 0
loc 629
rs 0
c 2
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
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
                    ->setParent($course)
1956
                    ->setCreator($course->getCreator() ?? null)
1957
                    ->addCourseLink($course, $session);
1958
                $em->persist($cTool);
1959
                $em->flush();
1960
1961
                $this->dlog('restore_tool_intro: CTool created', [
1962
                    'tool' => $toolKey,
1963
                    'ctool_id' => (int) $cTool->getIid(),
1964
                ]);
1965
            }
1966
1967
            // Create/overwrite intro according to file policy
1968
            $intro = $introRepo->findOneBy(['courseTool' => $cTool]);
1969
1970
            if ($intro) {
1971
                if (FILE_SKIP === $this->file_option) {
1972
                    $this->dlog('restore_tool_intro: reuse existing (SKIP)', [
1973
                        'tool' => $toolKey,
1974
                        'intro_id' => (int) $intro->getIid(),
1975
                    ]);
1976
                } else {
1977
                    $intro->setIntroText($introHtml);
1978
                    $em->persist($intro);
1979
                    $em->flush();
1980
1981
                    $this->dlog('restore_tool_intro: intro overwritten', [
1982
                        'tool' => $toolKey,
1983
                        'intro_id' => (int) $intro->getIid(),
1984
                    ]);
1985
                }
1986
            } else {
1987
                $intro = (new CToolIntro())
1988
                    ->setCourseTool($cTool)
1989
                    ->setIntroText($introHtml)
1990
                    ->setParent($course);
1991
                $em->persist($intro);
1992
                $em->flush();
1993
1994
                $this->dlog('restore_tool_intro: intro created', [
1995
                    'tool' => $toolKey,
1996
                    'intro_id' => (int) $intro->getIid(),
1997
                ]);
1998
            }
1999
2000
            // Map destination id back into the bucket used
2001
            $this->course->resources[$bagKey][$rawId] ??= new \stdClass();
2002
            $this->course->resources[$bagKey][$rawId]->destination_id = (int) $intro->getIid();
2003
        }
2004
2005
        $this->dlog('restore_tool_intro: end');
2006
    }
2007
2008
    /**
2009
     * Restore calendar events.
2010
     */
2011
    public function restore_events(int $sessionId = 0): void
2012
    {
2013
        if (!$this->course->has_resources(RESOURCE_EVENT)) {
2014
            return;
2015
        }
2016
2017
        $resources = $this->course->resources ?? [];
2018
        $bag = $resources[RESOURCE_EVENT] ?? [];
2019
        $count = \is_array($bag) ? \count($bag) : 0;
2020
2021
        $this->dlog('restore_events: begin', ['count' => $count]);
2022
2023
        /** @var EntityManagerInterface $em */
2024
        $em = Database::getManager();
2025
        $course = api_get_course_entity($this->destination_course_id);
2026
        $session = api_get_session_entity($sessionId);
2027
        $group = api_get_group_entity();
2028
        $eventRepo = Container::getCalendarEventRepository();
2029
        $attachRepo = Container::getCalendarEventAttachmentRepository();
2030
2031
        // Dedupe by title inside same course/session
2032
        $findExistingByTitle = function (string $title) use ($eventRepo, $course, $session) {
2033
            $qb = $eventRepo->getResourcesByCourse($course, $session, null, null, true, true);
2034
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
2035
2036
            return $qb->getQuery()->getOneOrNullResult();
2037
        };
2038
2039
        $originPath = rtrim((string) ($this->course->backup_path ?? ''), '/').'/upload/calendar/';
2040
2041
        foreach ($bag as $oldId => $raw) {
2042
            // Already mapped?
2043
            $mapped = (int) ($raw->destination_id ?? 0);
2044
            if ($mapped > 0) {
2045
                $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]);
2046
2047
                continue;
2048
            }
2049
2050
            // Normalize + rewrite content
2051
            $title = trim((string) ($raw->title ?? ''));
2052
            if ('' === $title) {
2053
                $title = 'Event';
2054
            }
2055
2056
            $content = $this->rewriteHtmlForCourse((string) ($raw->content ?? ''), $sessionId, '[events.content]');
2057
2058
            // Dates
2059
            $allDay = (bool) ($raw->all_day ?? false);
2060
            $start = null;
2061
            $end = null;
2062
2063
            try {
2064
                $s = (string) ($raw->start_date ?? '');
2065
                if ('' !== $s) {
2066
                    $start = new DateTime($s);
2067
                }
2068
            } catch (Throwable) {
2069
            }
2070
2071
            try {
2072
                $e = (string) ($raw->end_date ?? '');
2073
                if ('' !== $e) {
2074
                    $end = new DateTime($e);
2075
                }
2076
            } catch (Throwable) {
2077
            }
2078
2079
            // Dedupe policy
2080
            $existing = $findExistingByTitle($title);
2081
            if ($existing) {
2082
                switch ($this->file_option) {
2083
                    case FILE_SKIP:
2084
                        $destId = (int) $existing->getIid();
2085
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2086
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
2087
                        $this->dlog('restore_events: reuse (SKIP)', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle()]);
2088
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
2089
2090
                        continue 2;
2091
2092
                    case FILE_OVERWRITE:
2093
                        $existing
2094
                            ->setTitle($title)
2095
                            ->setContent($content)
2096
                            ->setAllDay($allDay)
2097
                            ->setParent($course)
2098
                            ->addCourseLink($course, $session, $group)
2099
                        ;
2100
                        $existing->setStartDate($start);
2101
                        $existing->setEndDate($end);
2102
2103
                        $em->persist($existing);
2104
                        $em->flush();
2105
2106
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2107
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int) $existing->getIid();
2108
2109
                        $this->dlog('restore_events: overwrite', ['src_id' => (int) $oldId, 'dst_id' => (int) $existing->getIid(), 'title' => $title]);
2110
2111
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
2112
2113
                        continue 2;
2114
2115
                    case FILE_RENAME:
2116
                    default:
2117
                        $base = $title;
2118
                        $i = 1;
2119
                        $candidate = $base;
2120
                        while ($findExistingByTitle($candidate)) {
2121
                            $candidate = $base.' ('.(++$i).')';
2122
                        }
2123
                        $title = $candidate;
2124
2125
                        break;
2126
                }
2127
            }
2128
2129
            // Create new event
2130
            $entity = (new CCalendarEvent())
2131
                ->setTitle($title)
2132
                ->setContent($content)
2133
                ->setAllDay($allDay)
2134
                ->setParent($course)
2135
                ->addCourseLink($course, $session, $group)
2136
            ;
2137
2138
            $entity->setStartDate($start);
2139
            $entity->setEndDate($end);
2140
2141
            $em->persist($entity);
2142
            $em->flush();
2143
2144
            $destId = (int) $entity->getIid();
2145
            $this->course->resources[RESOURCE_EVENT][$oldId] ??= new stdClass();
2146
            $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
2147
2148
            $this->dlog('restore_events: created', ['src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title]);
2149
2150
            // Attachments
2151
            $this->restoreEventAttachments($raw, $entity, $originPath, $attachRepo, $em);
2152
        }
2153
2154
        $this->dlog('restore_events: end');
2155
    }
2156
2157
    /**
2158
     * Handle event attachments.
2159
     *
2160
     * @param mixed $attachRepo
2161
     */
2162
    private function restoreEventAttachments(
2163
        object $raw,
2164
        CCalendarEvent $entity,
2165
        string $originPath,
2166
        $attachRepo,
2167
        EntityManagerInterface $em
2168
    ): void {
2169
        // Helper to actually persist + move file
2170
        $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em): void {
2171
            if (!is_file($src) || !is_readable($src)) {
2172
                $this->dlog('restore_events: attachment source not readable', ['src' => $src]);
2173
2174
                return;
2175
            }
2176
2177
            // Avoid duplicate filenames on same event
2178
            foreach ($entity->getAttachments() as $att) {
2179
                if ($att->getFilename() === $filename) {
2180
                    $this->dlog('restore_events: attachment already exists, skipping', ['filename' => $filename]);
2181
2182
                    return;
2183
                }
2184
            }
2185
2186
            $attachment = (new CCalendarEventAttachment())
2187
                ->setFilename($filename)
2188
                ->setComment($comment ?? '')
2189
                ->setEvent($entity)
2190
                ->setParent($entity)
2191
                ->addCourseLink(
2192
                    api_get_course_entity($this->destination_course_id),
2193
                    api_get_session_entity(0),
2194
                    api_get_group_entity()
2195
                )
2196
            ;
2197
2198
            $em->persist($attachment);
2199
            $em->flush();
2200
2201
            if (method_exists($attachRepo, 'addFileFromLocalPath')) {
2202
                $attachRepo->addFileFromLocalPath($attachment, $src);
2203
            } else {
2204
                $dstDir = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/calendar/';
2205
                @mkdir($dstDir, 0775, true);
2206
                $newName = uniqid('calendar_', true);
2207
                @copy($src, $dstDir.$newName);
2208
            }
2209
2210
            $this->dlog('restore_events: attachment created', [
2211
                'event_id' => (int) $entity->getIid(),
2212
                'filename' => $filename,
2213
            ]);
2214
        };
2215
2216
        // modern backup fields on object
2217
        if (!empty($raw->attachment_path)) {
2218
            $src = rtrim($originPath, '/').'/'.$raw->attachment_path;
2219
            $filename = (string) ($raw->attachment_filename ?? basename($src));
2220
            $comment = (string) ($raw->attachment_comment ?? '');
2221
            $persistAttachmentFromFile($src, $filename, $comment);
2222
2223
            return;
2224
        }
2225
2226
        // legacy lookup from old course tables when ->orig present
2227
        if (!empty($this->course->orig)) {
2228
            $table = Database::get_course_table(TABLE_AGENDA_ATTACHMENT);
2229
            $sql = 'SELECT path, comment, filename
2230
                FROM '.$table.'
2231
                WHERE c_id = '.$this->destination_course_id.'
2232
                  AND agenda_id = '.(int) ($raw->source_id ?? 0);
2233
            $res = Database::query($sql);
2234
            while ($row = Database::fetch_object($res)) {
2235
                $src = rtrim($originPath, '/').'/'.$row->path;
2236
                $persistAttachmentFromFile($src, (string) $row->filename, (string) $row->comment);
2237
            }
2238
        }
2239
    }
2240
2241
    /**
2242
     * Restore course descriptions.
2243
     *
2244
     * @param mixed $session_id
2245
     */
2246
    public function restore_course_descriptions($session_id = 0): void
2247
    {
2248
        if (!$this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) {
2249
            return;
2250
        }
2251
2252
        $resources = $this->course->resources ?? [];
2253
        $count = \is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null)
2254
            ? \count($resources[RESOURCE_COURSEDESCRIPTION]) : 0;
2255
2256
        $this->dlog('restore_course_descriptions: begin', ['count' => $count]);
2257
2258
        $em = Database::getManager();
2259
        $repo = Container::getCourseDescriptionRepository();
2260
        $course = api_get_course_entity($this->destination_course_id);
2261
        $session = api_get_session_entity((int) $session_id);
2262
2263
        $findByTypeInCourse = function (int $type) use ($repo, $course, $session) {
2264
            if (method_exists($repo, 'findByTypeInCourse')) {
2265
                return $repo->findByTypeInCourse($type, $course, $session);
2266
            }
2267
            $qb = $repo->getResourcesByCourse($course, $session)
2268
                ->andWhere('resource.descriptionType = :t')
2269
                ->setParameter('t', $type)
2270
            ;
2271
2272
            return $qb->getQuery()->getResult();
2273
        };
2274
2275
        $findByTitleInCourse = function (string $title) use ($repo, $course, $session) {
2276
            $qb = $repo->getResourcesByCourse($course, $session)
2277
                ->andWhere('resource.title = :t')
2278
                ->setParameter('t', $title)
2279
                ->setMaxResults(1)
2280
            ;
2281
2282
            return $qb->getQuery()->getOneOrNullResult();
2283
        };
2284
2285
        foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $oldId => $cd) {
2286
            // Already mapped?
2287
            $mapped = (int) ($cd->destination_id ?? 0);
2288
            if ($mapped > 0) {
2289
                $this->dlog('restore_course_descriptions: already mapped, skipping', ['src_id' => (int) $oldId, 'dst_id' => $mapped]);
2290
2291
                continue;
2292
            }
2293
2294
            // Normalize + rewrite
2295
            $rawTitle = (string) ($cd->title ?? '');
2296
            $rawContent = (string) ($cd->content ?? '');
2297
            $type = (int) ($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION);
2298
2299
            $title = '' !== trim($rawTitle) ? trim($rawTitle) : $rawTitle;
2300
            $content = $this->rewriteHtmlForCourse($rawContent, (int) $session_id, '[course_description.content]');
2301
2302
            // Policy by type
2303
            $existingByType = $findByTypeInCourse($type);
2304
            $existingOne = $existingByType[0] ?? null;
2305
2306
            if ($existingOne) {
2307
                switch ($this->file_option) {
2308
                    case FILE_SKIP:
2309
                        $destIid = (int) $existingOne->getIid();
2310
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2311
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2312
2313
                        $this->dlog('restore_course_descriptions: reuse (SKIP)', [
2314
                            'src_id' => (int) $oldId,
2315
                            'dst_id' => $destIid,
2316
                            'type' => $type,
2317
                            'title' => (string) $existingOne->getTitle(),
2318
                        ]);
2319
2320
                        continue 2;
2321
2322
                    case FILE_OVERWRITE:
2323
                        $existingOne
2324
                            ->setTitle('' !== $title ? $title : (string) $existingOne->getTitle())
2325
                            ->setContent($content)
2326
                            ->setDescriptionType($type)
2327
                            ->setProgress((int) ($cd->progress ?? 0))
2328
                        ;
2329
                        $existingOne->setParent($course)->addCourseLink($course, $session);
2330
2331
                        $em->persist($existingOne);
2332
                        $em->flush();
2333
2334
                        $destIid = (int) $existingOne->getIid();
2335
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2336
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2337
2338
                        $this->dlog('restore_course_descriptions: overwrite', [
2339
                            'src_id' => (int) $oldId,
2340
                            'dst_id' => $destIid,
2341
                            'type' => $type,
2342
                            'title' => (string) $existingOne->getTitle(),
2343
                        ]);
2344
2345
                        continue 2;
2346
2347
                    case FILE_RENAME:
2348
                    default:
2349
                        $base = '' !== $title ? $title : (string) ($cd->extra['title'] ?? 'Description');
2350
                        $i = 1;
2351
                        $candidate = $base;
2352
                        while ($findByTitleInCourse($candidate)) {
2353
                            $candidate = $base.' ('.(++$i).')';
2354
                        }
2355
                        $title = $candidate;
2356
2357
                        break;
2358
                }
2359
            }
2360
2361
            // Create new
2362
            $entity = (new CCourseDescription())
2363
                ->setTitle($title)
2364
                ->setContent($content)
2365
                ->setDescriptionType($type)
2366
                ->setProgress((int) ($cd->progress ?? 0))
2367
                ->setParent($course)
2368
                ->addCourseLink($course, $session)
2369
            ;
2370
2371
            $em->persist($entity);
2372
            $em->flush();
2373
2374
            $destIid = (int) $entity->getIid();
2375
            $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new stdClass();
2376
            $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2377
2378
            $this->dlog('restore_course_descriptions: created', [
2379
                'src_id' => (int) $oldId,
2380
                'dst_id' => $destIid,
2381
                'type' => $type,
2382
                'title' => $title,
2383
            ]);
2384
        }
2385
2386
        $this->dlog('restore_course_descriptions: end');
2387
    }
2388
2389
    /**
2390
     * Restore announcements into the destination course.
2391
     *
2392
     * @param mixed $sessionId
2393
     */
2394
    public function restore_announcements($sessionId = 0): void
2395
    {
2396
        if (!$this->course->has_resources(RESOURCE_ANNOUNCEMENT)) {
2397
            return;
2398
        }
2399
2400
        $sessionId = (int) $sessionId;
2401
        $resources = $this->course->resources;
2402
2403
        $count = \is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null)
2404
            ? \count($resources[RESOURCE_ANNOUNCEMENT])
2405
            : 0;
2406
2407
        $this->dlog('restore_announcements: begin', ['count' => $count]);
2408
2409
        /** @var EntityManagerInterface $em */
2410
        $em = Database::getManager();
2411
        $course = api_get_course_entity($this->destination_course_id);
2412
        $session = api_get_session_entity($sessionId);
2413
        $group = api_get_group_entity();
2414
        $annRepo = Container::getAnnouncementRepository();
2415
        $attachRepo = Container::getAnnouncementAttachmentRepository();
2416
2417
        // Origin path for ZIP/imported attachments (kept as-is)
2418
        $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/';
2419
2420
        // Finder: existing announcement by title in this course/session
2421
        $findExistingByTitle = function (string $title) use ($annRepo, $course, $session) {
2422
            $qb = $annRepo->getResourcesByCourse($course, $session);
2423
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
2424
2425
            return $qb->getQuery()->getOneOrNullResult();
2426
        };
2427
2428
        foreach ($resources[RESOURCE_ANNOUNCEMENT] as $oldId => $a) {
2429
            // Already mapped?
2430
            $mapped = (int) ($a->destination_id ?? 0);
2431
            if ($mapped > 0) {
2432
                $this->dlog('restore_announcements: already mapped, skipping', [
2433
                    'src_id' => (int) $oldId, 'dst_id' => $mapped,
2434
                ]);
2435
2436
                continue;
2437
            }
2438
2439
            $title = trim((string) ($a->title ?? ''));
2440
            if ('' === $title) {
2441
                $title = 'Announcement';
2442
            }
2443
2444
            $contentHtml = (string) ($a->content ?? '');
2445
2446
            // Parse optional end date
2447
            $endDate = null;
2448
2449
            try {
2450
                $rawDate = (string) ($a->date ?? '');
2451
                if ('' !== $rawDate) {
2452
                    $endDate = new DateTime($rawDate);
2453
                }
2454
            } catch (Throwable $e) {
2455
                $endDate = null;
2456
            }
2457
2458
            $emailSent = (bool) ($a->email_sent ?? false);
2459
2460
            $existing = $findExistingByTitle($title);
2461
            if ($existing) {
2462
                switch ($this->file_option) {
2463
                    case FILE_SKIP:
2464
                        $destId = (int) $existing->getIid();
2465
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass();
2466
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2467
                        $this->dlog('restore_announcements: reuse (SKIP)', [
2468
                            'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $existing->getTitle(),
2469
                        ]);
2470
                        // Still try to restore attachments on the reused entity
2471
                        $this->restoreAnnouncementAttachments($a, $existing, $originPath, $attachRepo, $em);
2472
2473
                        continue 2;
2474
2475
                    case FILE_OVERWRITE:
2476
                        // Continue to overwrite below
2477
                        break;
2478
2479
                    case FILE_RENAME:
2480
                    default:
2481
                        // Rename to avoid collision
2482
                        $base = $title;
2483
                        $i = 1;
2484
                        $candidate = $base;
2485
                        while ($findExistingByTitle($candidate)) {
2486
                            $i++;
2487
                            $candidate = $base.' ('.$i.')';
2488
                        }
2489
                        $title = $candidate;
2490
2491
                        break;
2492
                }
2493
            }
2494
2495
            // Rewrite HTML content using centralized helper (replaces manual mapping logic)
2496
            // Note: keeps attachments restoration logic unchanged.
2497
            $contentRewritten = $this->rewriteHtmlForCourse($contentHtml, $sessionId, '[announcements.content]');
2498
2499
            // Create or reuse entity
2500
            $entity = $existing ?: (new CAnnouncement());
2501
            $entity
2502
                ->setTitle($title)
2503
                ->setContent($contentRewritten) // content already rewritten
2504
                ->setParent($course)
2505
                ->addCourseLink($course, $session, $group)
2506
                ->setEmailSent($emailSent)
2507
            ;
2508
2509
            if ($endDate instanceof DateTimeInterface) {
2510
                $entity->setEndDate($endDate);
2511
            }
2512
2513
            $em->persist($entity);
2514
            $em->flush();
2515
2516
            $destId = (int) $entity->getIid();
2517
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new stdClass();
2518
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2519
2520
            $this->dlog($existing ? 'restore_announcements: overwrite' : 'restore_announcements: created', [
2521
                'src_id' => (int) $oldId, 'dst_id' => $destId, 'title' => $title,
2522
            ]);
2523
2524
            // Handle binary attachments from backup or source
2525
            $this->restoreAnnouncementAttachments($a, $entity, $originPath, $attachRepo, $em);
2526
        }
2527
2528
        $this->dlog('restore_announcements: end');
2529
    }
2530
2531
    /**
2532
     * Create/update CAnnouncementAttachment + ResourceFile for each attachment of an announcement.
2533
     * Sources:
2534
     *  - COPY mode (no ZIP):   from source announcement's ResourceFiles
2535
     *  - IMPORT mode (ZIP):    from /upload/announcements/* inside the package.
2536
     *
2537
     * Policies (by filename within the same announcement):
2538
     *  - FILE_SKIP:       skip if filename exists
2539
     *  - FILE_OVERWRITE:  reuse existing CAnnouncementAttachment and replace its ResourceFile
2540
     *  - FILE_RENAME:     create a new CAnnouncementAttachment with incremental suffix
2541
     */
2542
    private function restoreAnnouncementAttachments(
2543
        object $a,
2544
        CAnnouncement $entity,
2545
        string $originPath,
2546
        CAnnouncementAttachmentRepository $attachRepo,
2547
        EntityManagerInterface $em
2548
    ): void {
2549
        $copyMode = empty($this->course->backup_path);
2550
2551
        $findExistingByName = static function (CAnnouncement $ann, string $name) {
2552
            foreach ($ann->getAttachments() as $att) {
2553
                if ($att->getFilename() === $name) {
2554
                    return $att;
2555
                }
2556
            }
2557
2558
            return null;
2559
        };
2560
2561
        /**
2562
         * Decide target entity + final filename according to file policy.
2563
         * Returns [CAnnouncementAttachment|null $target, string|null $finalName, bool $isOverwrite].
2564
         */
2565
        $decideTarget = function (string $proposed, CAnnouncement $ann) use ($findExistingByName): array {
2566
            $policy = (int) $this->file_option;
2567
2568
            $existing = $findExistingByName($ann, $proposed);
2569
            if (!$existing) {
2570
                return [null, $proposed, false];
2571
            }
2572
2573
            if (\defined('FILE_SKIP') && FILE_SKIP === $policy) {
2574
                return [null, null, false];
2575
            }
2576
            if (\defined('FILE_OVERWRITE') && FILE_OVERWRITE === $policy) {
2577
                return [$existing, $proposed, true];
2578
            }
2579
2580
            $pi = pathinfo($proposed);
2581
            $base = $pi['filename'] ?? $proposed;
2582
            $ext = isset($pi['extension']) && '' !== $pi['extension'] ? ('.'.$pi['extension']) : '';
2583
            $i = 1;
2584
            do {
2585
                $candidate = $base.'_'.$i.$ext;
2586
                $i++;
2587
            } while ($findExistingByName($ann, $candidate));
2588
2589
            return [null, $candidate, false];
2590
        };
2591
2592
        $createAttachment = function (string $filename, string $comment, int $size) use ($entity, $em) {
2593
            $att = (new CAnnouncementAttachment())
2594
                ->setFilename($filename)
2595
                ->setPath(uniqid('announce_', true))
2596
                ->setComment($comment)
2597
                ->setSize($size)
2598
                ->setAnnouncement($entity)
2599
                ->setParent($entity)
2600
                ->addCourseLink(
2601
                    api_get_course_entity($this->destination_course_id),
2602
                    api_get_session_entity(0),
2603
                    api_get_group_entity()
2604
                )
2605
            ;
2606
            $em->persist($att);
2607
            $em->flush();
2608
2609
            return $att;
2610
        };
2611
2612
        /**
2613
         * Search helper: try a list of absolute paths, then recursive search in a base dir by filename.
2614
         * Returns ['src'=>abs, 'filename'=>..., 'comment'=>..., 'size'=>int] or null.
2615
         */
2616
        $resolveSourceFile = function (array $candidates, array $fallbackDirs, string $filename) {
2617
            // 1) direct candidates (absolute paths)
2618
            foreach ($candidates as $meta) {
2619
                if (!empty($meta['src']) && is_file($meta['src']) && is_readable($meta['src'])) {
2620
                    $meta['filename'] = $meta['filename'] ?: basename($meta['src']);
2621
                    $meta['size'] = (int) ($meta['size'] ?: (filesize($meta['src']) ?: 0));
2622
2623
                    return $meta;
2624
                }
2625
            }
2626
2627
            // 2) recursive search by filename inside fallback dirs
2628
            $filename = trim($filename);
2629
            if ('' !== $filename) {
2630
                foreach ($fallbackDirs as $base) {
2631
                    $base = rtrim($base, '/').'/';
2632
                    if (!is_dir($base)) {
2633
                        continue;
2634
                    }
2635
                    $it = new RecursiveIteratorIterator(
2636
                        new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
2637
                        RecursiveIteratorIterator::SELF_FIRST
2638
                    );
2639
                    foreach ($it as $f) {
2640
                        if ($f->isFile() && $f->getFilename() === $filename) {
2641
                            return [
2642
                                'src' => $f->getRealPath(),
2643
                                'filename' => $filename,
2644
                                'comment' => (string) ($candidates[0]['comment'] ?? ''),
2645
                                'size' => (int) ($candidates[0]['size'] ?? (filesize($f->getRealPath()) ?: 0)),
2646
                            ];
2647
                        }
2648
                    }
2649
                }
2650
            }
2651
2652
            return null;
2653
        };
2654
2655
        $storeBinaryFromPath = function (
2656
            CAnnouncementAttachment $target,
2657
            string $absPath
2658
        ) use ($attachRepo): void {
2659
            // This exists in your ResourceRepository
2660
            $attachRepo->addFileFromPath($target, $target->getFilename(), $absPath, true);
2661
        };
2662
2663
        // ---------------------- COPY MODE (course->course) ----------------------
2664
        if ($copyMode) {
2665
            $srcAttachmentIds = [];
2666
2667
            if (!empty($a->attachment_source_id)) {
2668
                $srcAttachmentIds[] = (int) $a->attachment_source_id;
2669
            }
2670
            if (!empty($a->attachment_source_ids) && \is_array($a->attachment_source_ids)) {
2671
                foreach ($a->attachment_source_ids as $sid) {
2672
                    $sid = (int) $sid;
2673
                    if ($sid > 0) {
2674
                        $srcAttachmentIds[] = $sid;
2675
                    }
2676
                }
2677
            }
2678
            if (empty($srcAttachmentIds) && !empty($a->source_id)) {
2679
                $srcAnn = Container::getAnnouncementRepository()->find((int) $a->source_id);
2680
                if ($srcAnn) {
2681
                    $srcAtts = Container::getAnnouncementAttachmentRepository()->findBy(['announcement' => $srcAnn]);
2682
                    foreach ($srcAtts as $sa) {
2683
                        $srcAttachmentIds[] = (int) $sa->getIid();
2684
                    }
2685
                }
2686
            }
2687
2688
            if (empty($srcAttachmentIds)) {
2689
                $this->dlog('restore_announcements: no source attachments found in COPY mode', [
2690
                    'dst_announcement_id' => (int) $entity->getIid(),
2691
                ]);
2692
2693
                return;
2694
            }
2695
2696
            $attRepo = Container::getAnnouncementAttachmentRepository();
2697
2698
            foreach (array_unique($srcAttachmentIds) as $sid) {
2699
                /** @var CAnnouncementAttachment|null $srcAtt */
2700
                $srcAtt = $attRepo->find((int) $sid);
2701
                if (!$srcAtt) {
2702
                    continue;
2703
                }
2704
2705
                $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt);
2706
                if (!$abs) {
2707
                    $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]);
2708
2709
                    continue;
2710
                }
2711
2712
                $proposed = $srcAtt->getFilename() ?: basename($abs);
2713
                [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity);
2714
2715
                if (null === $finalName) {
2716
                    $this->dlog('restore_announcements: skipped due to FILE_SKIP policy', [
2717
                        'src_att_id' => $sid,
2718
                        'filename' => $proposed,
2719
                    ]);
2720
2721
                    continue;
2722
                }
2723
2724
                if (null === $targetAttachment) {
2725
                    $targetAttachment = $createAttachment(
2726
                        $finalName,
2727
                        (string) $srcAtt->getComment(),
2728
                        (int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0))
2729
                    );
2730
                } else {
2731
                    $targetAttachment
2732
                        ->setComment((string) $srcAtt->getComment())
2733
                        ->setSize((int) ($srcAtt->getSize() ?: (is_file($abs) ? filesize($abs) : 0)))
2734
                    ;
2735
                    $em->persist($targetAttachment);
2736
                    $em->flush();
2737
                }
2738
2739
                $storeBinaryFromPath($targetAttachment, $abs);
2740
2741
                $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'copied').' from ResourceFile', [
2742
                    'dst_announcement_id' => (int) $entity->getIid(),
2743
                    'filename' => $targetAttachment->getFilename(),
2744
                    'size' => $targetAttachment->getSize(),
2745
                ]);
2746
            }
2747
2748
            return;
2749
        }
2750
2751
        $candidates = [];
2752
2753
        // Primary (from serialized record)
2754
        if (!empty($a->attachment_path)) {
2755
            $maybe = rtrim($originPath, '/').'/'.$a->attachment_path;
2756
            $filename = (string) ($a->attachment_filename ?? '');
2757
            if (is_file($maybe)) {
2758
                $candidates[] = [
2759
                    'src' => $maybe,
2760
                    'filename' => '' !== $filename ? $filename : basename($maybe),
2761
                    'comment' => (string) ($a->attachment_comment ?? ''),
2762
                    'size' => (int) ($a->attachment_size ?? (filesize($maybe) ?: 0)),
2763
                ];
2764
            } elseif (is_dir($maybe)) {
2765
                $try = '' !== $filename ? $maybe.'/'.$filename : '';
2766
                if ('' !== $try && is_file($try)) {
2767
                    $candidates[] = [
2768
                        'src' => $try,
2769
                        'filename' => $filename,
2770
                        'comment' => (string) ($a->attachment_comment ?? ''),
2771
                        'size' => (int) ($a->attachment_size ?? (filesize($try) ?: 0)),
2772
                    ];
2773
                } else {
2774
                    $files = [];
2775
                    foreach (new FilesystemIterator($maybe, FilesystemIterator::SKIP_DOTS) as $f) {
2776
                        if ($f->isFile()) {
2777
                            $files[] = $f->getRealPath();
2778
                        }
2779
                    }
2780
                    if (1 === \count($files)) {
2781
                        $one = $files[0];
2782
                        $candidates[] = [
2783
                            'src' => $one,
2784
                            'filename' => '' !== $filename ? $filename : basename($one),
2785
                            'comment' => (string) ($a->attachment_comment ?? ''),
2786
                            'size' => (int) ($a->attachment_size ?? (filesize($one) ?: 0)),
2787
                        ];
2788
                    }
2789
                }
2790
            }
2791
        }
2792
2793
        // Fallback DB snapshot
2794
        if (!empty($this->course->orig)) {
2795
            $table = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT);
2796
            $sql = 'SELECT path, comment, size, filename
2797
                FROM '.$table.'
2798
                WHERE c_id = '.$this->destination_course_id.'
2799
                  AND announcement_id = '.(int) ($a->source_id ?? 0);
2800
            $res = Database::query($sql);
2801
            while ($row = Database::fetch_object($res)) {
2802
                $base = rtrim($originPath, '/').'/'.$row->path;
2803
                $abs = null;
2804
2805
                if (is_file($base)) {
2806
                    $abs = $base;
2807
                } elseif (is_dir($base)) {
2808
                    $try = $base.'/'.$row->filename;
2809
                    if (is_file($try)) {
2810
                        $abs = $try;
2811
                    } else {
2812
                        $files = [];
2813
                        foreach (new FilesystemIterator($base, FilesystemIterator::SKIP_DOTS) as $f) {
2814
                            if ($f->isFile()) {
2815
                                $files[] = $f->getRealPath();
2816
                            }
2817
                        }
2818
                        if (1 === \count($files)) {
2819
                            $abs = $files[0];
2820
                        }
2821
                    }
2822
                }
2823
2824
                if ($abs && is_readable($abs)) {
2825
                    $candidates[] = [
2826
                        'src' => $abs,
2827
                        'filename' => (string) $row->filename,
2828
                        'comment' => (string) $row->comment,
2829
                        'size' => (int) ($row->size ?: (filesize($abs) ?: 0)),
2830
                    ];
2831
                }
2832
            }
2833
        }
2834
2835
        $fallbackDirs = [
2836
            rtrim($this->course->backup_path ?? '', '/').'/upload/announcements',
2837
            rtrim($this->course->backup_path ?? '', '/').'/upload',
2838
        ];
2839
2840
        $preferredFilename = (string) ($a->attachment_filename ?? '');
2841
        if ('' === $preferredFilename && !empty($candidates)) {
2842
            $preferredFilename = (string) ($candidates[0]['filename'] ?? '');
2843
        }
2844
2845
        $resolved = $resolveSourceFile($candidates, $fallbackDirs, $preferredFilename);
2846
        if (!$resolved) {
2847
            $this->dlog('restore_announcements: no ZIP attachments could be resolved', [
2848
                'dst_announcement_id' => (int) $entity->getIid(),
2849
                'originPath' => $originPath,
2850
                'hint' => 'Check upload/announcements and upload paths inside the package',
2851
            ]);
2852
2853
            return;
2854
        }
2855
2856
        $proposed = $resolved['filename'] ?: basename($resolved['src']);
2857
        [$targetAttachment, $finalName, $isOverwrite] = $decideTarget($proposed, $entity);
2858
2859
        if (null === $finalName) {
2860
            $this->dlog('restore_announcements: skipped due to FILE_SKIP policy (ZIP)', [
2861
                'filename' => $proposed,
2862
            ]);
2863
2864
            return;
2865
        }
2866
2867
        if (null === $targetAttachment) {
2868
            $targetAttachment = $createAttachment(
2869
                $finalName,
2870
                (string) $resolved['comment'],
2871
                (int) $resolved['size']
2872
            );
2873
        } else {
2874
            $targetAttachment
2875
                ->setComment((string) $resolved['comment'])
2876
                ->setSize((int) $resolved['size'])
2877
            ;
2878
            $em->persist($targetAttachment);
2879
            $em->flush();
2880
        }
2881
2882
        $storeBinaryFromPath($targetAttachment, $resolved['src']);
2883
2884
        $this->dlog('restore_announcements: attachment '.($isOverwrite ? 'overwritten' : 'stored (ZIP)'), [
2885
            'announcement_id' => (int) $entity->getIid(),
2886
            'filename' => $targetAttachment->getFilename(),
2887
            'size' => $targetAttachment->getSize(),
2888
            'src' => $resolved['src'],
2889
        ]);
2890
    }
2891
2892
    /**
2893
     * Restore quizzes and their questions into the destination course.
2894
     *
2895
     * @param mixed $session_id
2896
     * @param mixed $respect_base_content
2897
     */
2898
    public function restore_quizzes($session_id = 0, $respect_base_content = false): void
2899
    {
2900
        if (!$this->course->has_resources(RESOURCE_QUIZ)) {
2901
            error_log('RESTORE_QUIZ: No quiz resources in backup.');
2902
2903
            return;
2904
        }
2905
2906
        $em = Database::getManager();
2907
        $resources = $this->course->resources;
2908
        $courseEntity = api_get_course_entity($this->destination_course_id);
2909
        $sessionEntity = !empty($session_id) ? api_get_session_entity((int) $session_id) : api_get_session_entity();
2910
2911
        // Safe wrapper around rewriteHtmlForCourse
2912
        $rw = function (?string $html, string $dbgTag = 'QZ') use ($session_id) {
2913
            if (null === $html || false === $html || '' === $html) {
2914
                return '';
2915
            }
2916
2917
            try {
2918
                return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag);
2919
            } catch (Throwable $e) {
2920
                error_log('RESTORE_QUIZ: rewriteHtmlForCourse failed: '.$e->getMessage());
2921
2922
                return (string) $html;
2923
            }
2924
        };
2925
2926
        // Backward compat alias for legacy key
2927
        if (empty($this->course->resources[RESOURCE_QUIZQUESTION])
2928
            && !empty($this->course->resources['Exercise_Question'])) {
2929
            $this->course->resources[RESOURCE_QUIZQUESTION] = $this->course->resources['Exercise_Question'];
2930
            $resources = $this->course->resources;
2931
            error_log('RESTORE_QUIZ: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
2932
        }
2933
2934
        foreach ($resources[RESOURCE_QUIZ] as $id => $quizWrap) {
2935
            if ((int) ($this->course->resources[RESOURCE_QUIZ][$id]->destination_id ?? 0) > 0) {
2936
                $this->dlog('RESTORE_QUIZ: already mapped, skipping', ['src_quiz_id' => (int) $id]);
2937
                continue;
2938
            }
2939
            $quiz = isset($quizWrap->obj) ? $quizWrap->obj : $quizWrap;
2940
2941
            // Rewrite HTML-bearing fields
2942
            $description = $rw($quiz->description ?? '', 'QZ.desc');
2943
            $textFinished = $rw($quiz->text_when_finished ?? '', 'QZ.done.ok');
2944
            $textFinishedKo = $rw($quiz->text_when_finished_failure ?? '', 'QZ.done.ko');
2945
2946
            // Normalize dates
2947
            $quiz->start_time = (property_exists($quiz, 'start_time') && '0000-00-00 00:00:00' !== $quiz->start_time)
2948
                ? $quiz->start_time
2949
                : null;
2950
            $quiz->end_time = (property_exists($quiz, 'end_time') && '0000-00-00 00:00:00' !== $quiz->end_time)
2951
                ? $quiz->end_time
2952
                : null;
2953
2954
            global $_custom;
2955
            if (!empty($_custom['exercises_clean_dates_when_restoring'])) {
2956
                $quiz->start_time = null;
2957
                $quiz->end_time = null;
2958
            }
2959
2960
            if (-1 === (int) $id) {
2961
                $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = -1;
2962
                error_log('RESTORE_QUIZ: Skipping virtual quiz (id=-1).');
2963
2964
                continue;
2965
            }
2966
2967
            $entity = (new CQuiz())
2968
                ->setParent($courseEntity)
2969
                ->addCourseLink(
2970
                    $courseEntity,
2971
                    $respect_base_content ? $sessionEntity : (!empty($session_id) ? $sessionEntity : api_get_session_entity()),
2972
                    api_get_group_entity()
2973
                )
2974
                ->setTitle((string) $quiz->title)
2975
                ->setDescription($description)
2976
                ->setType(isset($quiz->quiz_type) ? (int) $quiz->quiz_type : (int) $quiz->type)
2977
                ->setRandom((int) $quiz->random)
2978
                ->setRandomAnswers((bool) $quiz->random_answers)
2979
                ->setResultsDisabled((int) $quiz->results_disabled)
2980
                ->setMaxAttempt((int) $quiz->max_attempt)
2981
                ->setFeedbackType((int) $quiz->feedback_type)
2982
                ->setExpiredTime((int) $quiz->expired_time)
2983
                ->setReviewAnswers((int) $quiz->review_answers)
2984
                ->setRandomByCategory((int) $quiz->random_by_category)
2985
                ->setTextWhenFinished($textFinished)
2986
                ->setTextWhenFinishedFailure($textFinishedKo)
2987
                ->setDisplayCategoryName((int) ($quiz->display_category_name ?? 0))
2988
                ->setSaveCorrectAnswers(isset($quiz->save_correct_answers) ? (int) $quiz->save_correct_answers : 0)
2989
                ->setPropagateNeg((int) $quiz->propagate_neg)
2990
                ->setHideQuestionTitle((bool) ($quiz->hide_question_title ?? false))
2991
                ->setHideQuestionNumber((int) ($quiz->hide_question_number ?? 0))
2992
                ->setStartTime(!empty($quiz->start_time) ? new DateTime((string) $quiz->start_time) : null)
2993
                ->setEndTime(!empty($quiz->end_time) ? new DateTime((string) $quiz->end_time) : null)
2994
            ;
2995
2996
            if (isset($quiz->access_condition) && '' !== $quiz->access_condition) {
2997
                $entity->setAccessCondition((string) $quiz->access_condition);
2998
            }
2999
            if (isset($quiz->pass_percentage) && '' !== $quiz->pass_percentage && null !== $quiz->pass_percentage) {
3000
                $entity->setPassPercentage((int) $quiz->pass_percentage);
3001
            }
3002
            if (isset($quiz->question_selection_type) && '' !== $quiz->question_selection_type && null !== $quiz->question_selection_type) {
3003
                $entity->setQuestionSelectionType((int) $quiz->question_selection_type);
3004
            }
3005
            if ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')) {
3006
                $entity->setNotifications((string) ($quiz->notifications ?? ''));
3007
            }
3008
3009
            $em->persist($entity);
3010
            $em->flush();
3011
3012
            $newQuizId = (int) $entity->getIid();
3013
            $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $newQuizId;
3014
3015
            $qCount = isset($quiz->question_ids) ? \count((array) $quiz->question_ids) : 0;
3016
            error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string) $quiz->title.'" with '.$qCount.' question ids.');
3017
3018
            $order = 0;
3019
            if (!empty($quiz->question_ids)) {
3020
                foreach ($quiz->question_ids as $index => $question_id) {
3021
                    $qid = $this->restore_quiz_question($question_id, (int) $session_id);
3022
                    if (!$qid) {
3023
                        error_log('RESTORE_QUIZ: restore_quiz_question returned 0 for src_question_id='.$question_id);
3024
3025
                        continue;
3026
                    }
3027
3028
                    $question_order = !empty($quiz->question_orders[$index])
3029
                        ? (int) $quiz->question_orders[$index]
3030
                        : $order;
3031
3032
                    $order++;
3033
3034
                    $questionEntity = $em->getRepository(CQuizQuestion::class)->find($qid);
3035
                    if (!$questionEntity) {
3036
                        error_log('RESTORE_QUIZ: Question entity not found after insert. qid='.$qid);
3037
3038
                        continue;
3039
                    }
3040
3041
                    $rel = (new CQuizRelQuestion())
3042
                        ->setQuiz($entity)
3043
                        ->setQuestion($questionEntity)
3044
                        ->setQuestionOrder($question_order)
3045
                    ;
3046
3047
                    $em->persist($rel);
3048
                    $em->flush();
3049
                }
3050
            } else {
3051
                error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string) $quiz->title.'").');
3052
            }
3053
        }
3054
    }
3055
3056
    /**
3057
     * Restore quiz-questions. Returns new question IID.
3058
     *
3059
     * @param mixed $id
3060
     */
3061
    public function restore_quiz_question($id, int $session_id = 0)
3062
    {
3063
        $em = Database::getManager();
3064
        $resources = $this->course->resources;
3065
3066
        if (empty($resources[RESOURCE_QUIZQUESTION]) && !empty($resources['Exercise_Question'])) {
3067
            $resources[RESOURCE_QUIZQUESTION] = $this->course->resources[RESOURCE_QUIZQUESTION]
3068
                = $this->course->resources['Exercise_Question'];
3069
            error_log('RESTORE_QUESTION: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
3070
        }
3071
3072
        /** @var object|null $question */
3073
        $question = $resources[RESOURCE_QUIZQUESTION][$id] ?? null;
3074
        if (!\is_object($question)) {
3075
            error_log('RESTORE_QUESTION: Question not found in resources. src_id='.$id);
3076
3077
            return 0;
3078
        }
3079
        if (method_exists($question, 'is_restored') && $question->is_restored()) {
3080
            return (int) $question->destination_id;
3081
        }
3082
3083
        $courseEntity = api_get_course_entity($this->destination_course_id);
3084
3085
        // Safe wrapper around rewriteHtmlForCourse
3086
        $rw = function (?string $html, string $dbgTag = 'QZ.Q') use ($session_id) {
3087
            if (null === $html || false === $html || '' === $html) {
3088
                return '';
3089
            }
3090
3091
            try {
3092
                return $this->rewriteHtmlForCourse((string) $html, (int) $session_id, $dbgTag);
3093
            } catch (Throwable $e) {
3094
                error_log('RESTORE_QUESTION: rewriteHtmlForCourse failed: '.$e->getMessage());
3095
3096
                return (string) $html;
3097
            }
3098
        };
3099
3100
        // Rewrite statement & description
3101
        $question->description = $rw($question->description ?? '', 'QZ.Q.desc');
3102
        $question->question = $rw($question->question ?? '', 'QZ.Q.text');
3103
3104
        // Picture mapping (kept as in your code)
3105
        $imageNewId = '';
3106
        if (!empty($question->picture)) {
3107
            if (isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture])) {
3108
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]['destination_id'];
3109
            } elseif (isset($resources[RESOURCE_DOCUMENT][$question->picture])) {
3110
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT][$question->picture]->destination_id;
3111
            }
3112
        }
3113
3114
        $qType = (int) ($question->quiz_type ?? $question->type);
3115
        $entity = (new CQuizQuestion())
3116
            ->setParent($courseEntity)
3117
            ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity())
3118
            ->setQuestion($question->question)
3119
            ->setDescription($question->description)
3120
            ->setPonderation((float) ($question->ponderation ?? 0))
3121
            ->setPosition((int) ($question->position ?? 1))
3122
            ->setType($qType)
3123
            ->setPicture($imageNewId)
3124
            ->setLevel((int) ($question->level ?? 1))
3125
            ->setExtra((string) ($question->extra ?? ''))
3126
        ;
3127
3128
        $em->persist($entity);
3129
        $em->flush();
3130
3131
        $new_id = (int) $entity->getIid();
3132
        if (!$new_id) {
3133
            error_log('RESTORE_QUESTION: Failed to obtain new question iid for src_id='.$id);
3134
3135
            return 0;
3136
        }
3137
3138
        $answers = (array) ($question->answers ?? []);
3139
        error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.\count($answers));
3140
3141
        $isMatchingFamily = \in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true);
3142
        $correctMapSrcToDst = []; // dstAnsId => srcCorrectRef
3143
        $allSrcAnswersById = []; // srcAnsId => text
3144
        $dstAnswersByIdText = []; // dstAnsId => text
3145
3146
        if ($isMatchingFamily) {
3147
            foreach ($answers as $a) {
3148
                $allSrcAnswersById[$a['id']] = $rw($a['answer'] ?? '', 'QZ.Q.ans.all');
3149
            }
3150
        }
3151
3152
        foreach ($answers as $a) {
3153
            $ansText = $rw($a['answer'] ?? '', 'QZ.Q.ans');
3154
            $comment = $rw($a['comment'] ?? '', 'QZ.Q.ans.cmt');
3155
3156
            $ans = (new CQuizAnswer())
3157
                ->setQuestion($entity)
3158
                ->setAnswer((string) $ansText)
3159
                ->setComment((string) $comment)
3160
                ->setPonderation((float) ($a['ponderation'] ?? 0))
3161
                ->setPosition((int) ($a['position'] ?? 0))
3162
                ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string) $a['hotspot_coordinates'] : null)
3163
                ->setHotspotType(isset($a['hotspot_type']) ? (string) $a['hotspot_type'] : null)
3164
            ;
3165
3166
            if (isset($a['correct']) && '' !== $a['correct'] && null !== $a['correct']) {
3167
                $ans->setCorrect((int) $a['correct']);
3168
            }
3169
3170
            $em->persist($ans);
3171
            $em->flush();
3172
3173
            if ($isMatchingFamily) {
3174
                $correctMapSrcToDst[(int) $ans->getIid()] = $a['correct'] ?? null;
3175
                $dstAnswersByIdText[(int) $ans->getIid()] = $ansText;
3176
            }
3177
        }
3178
3179
        if ($isMatchingFamily && $correctMapSrcToDst) {
3180
            foreach ($entity->getAnswers() as $dstAns) {
3181
                $dstAid = (int) $dstAns->getIid();
3182
                $srcRef = $correctMapSrcToDst[$dstAid] ?? null;
3183
                if (null === $srcRef) {
3184
                    continue;
3185
                }
3186
3187
                if (isset($allSrcAnswersById[$srcRef])) {
3188
                    $needle = $allSrcAnswersById[$srcRef];
3189
                    $newDst = null;
3190
                    foreach ($dstAnswersByIdText as $candId => $txt) {
3191
                        if ($txt === $needle) {
3192
                            $newDst = $candId;
3193
3194
                            break;
3195
                        }
3196
                    }
3197
                    if (null !== $newDst) {
3198
                        $dstAns->setCorrect((int) $newDst);
3199
                        $em->persist($dstAns);
3200
                    }
3201
                }
3202
            }
3203
            $em->flush();
3204
        }
3205
3206
        if (\defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) {
3207
            $newOptByOld = [];
3208
            if (isset($question->question_options) && is_iterable($question->question_options)) {
3209
                foreach ($question->question_options as $optWrap) {
3210
                    $opt = $optWrap->obj ?? $optWrap;
3211
                    $optTitle = $rw($opt->name ?? '', 'QZ.Q.opt'); // rewrite option title too
3212
                    $optEntity = (new CQuizQuestionOption())
3213
                        ->setQuestion($entity)
3214
                        ->setTitle((string) $optTitle)
3215
                        ->setPosition((int) $opt->position)
3216
                    ;
3217
                    $em->persist($optEntity);
3218
                    $em->flush();
3219
                    $newOptByOld[$opt->id] = (int) $optEntity->getIid();
3220
                }
3221
                foreach ($entity->getAnswers() as $dstAns) {
3222
                    $corr = $dstAns->getCorrect();
3223
                    if (null !== $corr && isset($newOptByOld[$corr])) {
3224
                        $dstAns->setCorrect((int) $newOptByOld[$corr]);
3225
                        $em->persist($dstAns);
3226
                    }
3227
                }
3228
                $em->flush();
3229
            }
3230
        }
3231
3232
        $this->course->resources[RESOURCE_QUIZQUESTION][$id]->destination_id = $new_id;
3233
3234
        return $new_id;
3235
    }
3236
3237
    /**
3238
     * Restore surveys from backup into the destination course.
3239
     *
3240
     * @param mixed $sessionId
3241
     */
3242
    public function restore_surveys($sessionId = 0): void
3243
    {
3244
        if (!$this->course->has_resources(RESOURCE_SURVEY)) {
3245
            $this->debug && error_log('COURSE_DEBUG: restore_surveys: no survey resources in backup.');
3246
3247
            return;
3248
        }
3249
3250
        $em = Database::getManager();
3251
        $surveyRepo = Container::getSurveyRepository();
3252
3253
        /** @var CourseEntity $courseEntity */
3254
        $courseEntity = api_get_course_entity($this->destination_course_id);
3255
3256
        /** @var SessionEntity|null $sessionEntity */
3257
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
3258
3259
        $sid = (int) ($sessionEntity?->getId() ?? 0);
3260
3261
        $rewrite = function (?string $html, string $tag = '') use ($sid) {
3262
            if (null === $html || '' === $html) {
3263
                return '';
3264
            }
3265
3266
            return $this->rewriteHtmlForCourse((string) $html, $sid, $tag);
3267
        };
3268
3269
        $resources = $this->course->resources;
3270
3271
        foreach ($resources[RESOURCE_SURVEY] as $legacySurveyId => $surveyObj) {
3272
            try {
3273
                $code = (string) ($surveyObj->code ?? '');
3274
                $lang = (string) ($surveyObj->lang ?? '');
3275
3276
                $title = $rewrite($surveyObj->title ?? '', ':survey.title');
3277
                $subtitle = $rewrite($surveyObj->subtitle ?? '', ':survey.subtitle');
3278
                $intro = $rewrite($surveyObj->intro ?? '', ':survey.intro');
3279
                $surveyThanks = $rewrite($surveyObj->surveythanks ?? '', ':survey.thanks');
3280
3281
                $onePerPage = !empty($surveyObj->one_question_per_page);
3282
                $shuffle = isset($surveyObj->shuffle) ? (bool) $surveyObj->shuffle : (!empty($surveyObj->suffle));
3283
                $anonymous = (string) ((int) ($surveyObj->anonymous ?? 0));
3284
3285
                try {
3286
                    $creationDate = !empty($surveyObj->creation_date) ? new DateTime((string) $surveyObj->creation_date) : new DateTime();
3287
                } catch (Throwable) {
3288
                    $creationDate = new DateTime();
3289
                }
3290
3291
                try {
3292
                    $availFrom = !empty($surveyObj->avail_from) ? new DateTime((string) $surveyObj->avail_from) : null;
3293
                } catch (Throwable) {
3294
                    $availFrom = null;
3295
                }
3296
3297
                try {
3298
                    $availTill = !empty($surveyObj->avail_till) ? new DateTime((string) $surveyObj->avail_till) : null;
3299
                } catch (Throwable) {
3300
                    $availTill = null;
3301
                }
3302
3303
                $visibleResults = isset($surveyObj->visible_results) ? (int) $surveyObj->visible_results : null;
3304
                $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool) $surveyObj->display_question_number : true;
3305
3306
                $existing = null;
3307
3308
                try {
3309
                    if (method_exists($surveyRepo, 'findOneByCodeAndLangInCourse')) {
3310
                        $existing = $surveyRepo->findOneByCodeAndLangInCourse($courseEntity, $code, $lang);
3311
                    } else {
3312
                        $existing = $surveyRepo->findOneBy(['code' => $code, 'lang' => $lang]);
3313
                    }
3314
                } catch (Throwable $e) {
3315
                    $this->debug && error_log('COURSE_DEBUG: restore_surveys: duplicate check skipped: '.$e->getMessage());
3316
                }
3317
3318
                if ($existing instanceof CSurvey) {
3319
                    switch ($this->file_option) {
3320
                        case FILE_SKIP:
3321
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid();
3322
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: survey exists code='$code' (skip).");
3323
3324
                            continue 2;
3325
3326
                        case FILE_RENAME:
3327
                            $base = '' !== $code ? $code.'_' : 'survey_';
3328
                            $i = 1;
3329
                            $try = $base.$i;
3330
                            while (!$this->is_survey_code_available($try)) {
3331
                                $try = $base.(++$i);
3332
                            }
3333
                            $code = $try;
3334
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: renaming to '$code'.");
3335
3336
                            break;
3337
3338
                        case FILE_OVERWRITE:
3339
                            SurveyManager::deleteSurvey($existing);
3340
                            $em->flush();
3341
                            $this->debug && error_log('COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite).');
3342
3343
                            break;
3344
3345
                        default:
3346
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int) $existing->getIid();
3347
3348
                            continue 2;
3349
                    }
3350
                }
3351
3352
                // --- Create survey ---
3353
                $newSurvey = new CSurvey();
3354
                $newSurvey
3355
                    ->setCode($code)
3356
                    ->setTitle($title)
3357
                    ->setSubtitle($subtitle)
3358
                    ->setLang($lang)
3359
                    ->setAvailFrom($availFrom)
3360
                    ->setAvailTill($availTill)
3361
                    ->setIsShared((string) ($surveyObj->is_shared ?? '0'))
3362
                    ->setTemplate((string) ($surveyObj->template ?? 'template'))
3363
                    ->setIntro($intro)
3364
                    ->setSurveythanks($surveyThanks)
3365
                    ->setCreationDate($creationDate)
3366
                    ->setInvited(0)
3367
                    ->setAnswered(0)
3368
                    ->setInviteMail((string) ($surveyObj->invite_mail ?? ''))
3369
                    ->setReminderMail((string) ($surveyObj->reminder_mail ?? ''))
3370
                    ->setOneQuestionPerPage($onePerPage)
3371
                    ->setShuffle($shuffle)
3372
                    ->setAnonymous($anonymous)
3373
                    ->setDisplayQuestionNumber($displayQuestionNumber)
3374
                ;
3375
3376
                if (method_exists($newSurvey, 'setParent')) {
3377
                    $newSurvey->setParent($courseEntity);
3378
                }
3379
                if (method_exists($newSurvey, 'addCourseLink')) {
3380
                    $newSurvey->addCourseLink($courseEntity, $sessionEntity);
3381
                }
3382
3383
                if (method_exists($surveyRepo, 'create')) {
3384
                    $surveyRepo->create($newSurvey);
3385
                } else {
3386
                    $em->persist($newSurvey);
3387
                    $em->flush();
3388
                }
3389
3390
                if (null !== $visibleResults && method_exists($newSurvey, 'setVisibleResults')) {
3391
                    $newSurvey->setVisibleResults($visibleResults);
3392
                    $em->flush();
3393
                }
3394
3395
                $newId = (int) $newSurvey->getIid();
3396
                $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = $newId;
3397
3398
                // Restore questions
3399
                $questionIds = \is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : [];
3400
                if (empty($questionIds) && !empty($resources[RESOURCE_SURVEYQUESTION])) {
3401
                    foreach ($resources[RESOURCE_SURVEYQUESTION] as $qid => $qWrap) {
3402
                        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
3403
                        if ((int) ($q->survey_id ?? 0) === (int) $legacySurveyId) {
3404
                            $questionIds[] = (int) $qid;
3405
                        }
3406
                    }
3407
                }
3408
3409
                foreach ($questionIds as $legacyQid) {
3410
                    $this->restore_survey_question((int) $legacyQid, $newId, $sid);
3411
                }
3412
3413
                $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".\count($questionIds));
3414
            } catch (Throwable $e) {
3415
                error_log('COURSE_DEBUG: restore_surveys: failed: '.$e->getMessage());
3416
            }
3417
        }
3418
    }
3419
3420
    /**
3421
     * Restore survey-questions. $survey_id is the NEW iid.
3422
     *
3423
     * @param mixed $id
3424
     * @param mixed $survey_id
3425
     */
3426
    public function restore_survey_question($id, $survey_id, ?int $sid = null)
3427
    {
3428
        $resources = $this->course->resources;
3429
        $qWrap = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null;
3430
3431
        if (!$qWrap || !\is_object($qWrap)) {
3432
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: legacy question $id not found.");
3433
3434
            return 0;
3435
        }
3436
        if (method_exists($qWrap, 'is_restored') && $qWrap->is_restored()) {
3437
            return $qWrap->destination_id;
3438
        }
3439
3440
        $surveyRepo = Container::getSurveyRepository();
3441
        $em = Database::getManager();
3442
3443
        $survey = $surveyRepo->find((int) $survey_id);
3444
        if (!$survey instanceof CSurvey) {
3445
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: target survey $survey_id not found.");
3446
3447
            return 0;
3448
        }
3449
3450
        $sid = (int) ($sid ?? api_get_session_id());
3451
3452
        $rewrite = function (?string $html, string $tag = '') use ($sid) {
3453
            if (null === $html || '' === $html) {
3454
                return '';
3455
            }
3456
3457
            return $this->rewriteHtmlForCourse((string) $html, $sid, $tag);
3458
        };
3459
3460
        $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
3461
3462
        $questionText = $rewrite($q->survey_question ?? '', ':survey.q');
3463
        $commentText = $rewrite($q->survey_question_comment ?? '', ':survey.qc');
3464
3465
        try {
3466
            $question = new CSurveyQuestion();
3467
            $question
3468
                ->setSurvey($survey)
3469
                ->setSurveyQuestion($questionText)
3470
                ->setSurveyQuestionComment($commentText)
3471
                ->setType((string) ($q->survey_question_type ?? $q->type ?? 'open'))
3472
                ->setDisplay((string) ($q->display ?? 'vertical'))
3473
                ->setSort((int) ($q->sort ?? 0))
3474
            ;
3475
3476
            if (isset($q->shared_question_id) && method_exists($question, 'setSharedQuestionId')) {
3477
                $question->setSharedQuestionId((int) $q->shared_question_id);
3478
            }
3479
            if (isset($q->max_value) && method_exists($question, 'setMaxValue')) {
3480
                $question->setMaxValue((int) $q->max_value);
3481
            }
3482
            if (isset($q->is_required)) {
3483
                if (method_exists($question, 'setIsMandatory')) {
3484
                    $question->setIsMandatory((bool) $q->is_required);
3485
                } elseif (method_exists($question, 'setIsRequired')) {
3486
                    $question->setIsRequired((bool) $q->is_required);
3487
                }
3488
            }
3489
3490
            $em->persist($question);
3491
            $em->flush();
3492
3493
            // Options (value NOT NULL: default to 0 if missing)
3494
            $answers = \is_array($q->answers ?? null) ? $q->answers : [];
3495
            foreach ($answers as $idx => $answer) {
3496
                $optText = $rewrite($answer['option_text'] ?? '', ':survey.opt');
3497
                $value = isset($answer['value']) && null !== $answer['value'] ? (int) $answer['value'] : 0;
3498
                $sort = (int) ($answer['sort'] ?? ($idx + 1));
3499
3500
                $opt = new CSurveyQuestionOption();
3501
                $opt
3502
                    ->setSurvey($survey)
3503
                    ->setQuestion($question)
3504
                    ->setOptionText($optText)
3505
                    ->setSort($sort)
3506
                    ->setValue($value)
3507
                ;
3508
3509
                $em->persist($opt);
3510
            }
3511
            $em->flush();
3512
3513
            $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int) $question->getIid();
3514
3515
            return (int) $question->getIid();
3516
        } catch (Throwable $e) {
3517
            error_log('COURSE_DEBUG: restore_survey_question: failed: '.$e->getMessage());
3518
3519
            return 0;
3520
        }
3521
    }
3522
3523
    public function restore_learnpath_category(int $sessionId = 0, bool $baseContent = false): void
3524
    {
3525
        $reuseExisting = false;
3526
        if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing'])
3527
            && true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) {
3528
            $reuseExisting = true;
3529
        }
3530
3531
        if (!$this->course->has_resources(RESOURCE_LEARNPATH_CATEGORY)) {
3532
            return;
3533
        }
3534
3535
        $tblLpCategory = Database::get_course_table(TABLE_LP_CATEGORY);
3536
        $resources = $this->course->resources;
3537
3538
        /** @var LearnPathCategory $item */
3539
        foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) {
3540
            /** @var CLpCategory|null $lpCategory */
3541
            $lpCategory = $item->object;
3542
            if (!$lpCategory) {
3543
                continue;
3544
            }
3545
3546
            $title = trim((string) $lpCategory->getTitle());
3547
            if ('' === $title) {
3548
                continue;
3549
            }
3550
3551
            $categoryId = 0;
3552
3553
            $existing = Database::select(
3554
                'iid',
3555
                $tblLpCategory,
3556
                [
3557
                    'WHERE' => [
3558
                        'c_id = ? AND name = ?' => [$this->destination_course_id, $title],
3559
                    ],
3560
                ],
3561
                'first'
3562
            );
3563
3564
            if ($reuseExisting && !empty($existing) && !empty($existing['iid'])) {
3565
                $categoryId = (int) $existing['iid'];
3566
            } else {
3567
                $values = [
3568
                    'c_id' => $this->destination_course_id,
3569
                    'name' => $title,
3570
                ];
3571
                $categoryId = (int) learnpath::createCategory($values);
3572
            }
3573
3574
            if ($categoryId > 0) {
3575
                $this->course->resources[RESOURCE_LEARNPATH_CATEGORY][$id]->destination_id = $categoryId;
3576
            }
3577
        }
3578
    }
3579
3580
    /**
3581
     * Restore SCORM ZIPs under Documents (Learning paths) for traceability.
3582
     * Accepts real zips and on-the-fly temporary ones (temp will be deleted after upload).
3583
     */
3584
    public function restore_scorm_documents(): void
3585
    {
3586
        $logp = 'RESTORE_SCORM_ZIP: ';
3587
3588
        $getBucket = function (string $type) {
3589
            if (!empty($this->course->resources[$type]) && \is_array($this->course->resources[$type])) {
3590
                return $this->course->resources[$type];
3591
            }
3592
            foreach ($this->course->resources ?? [] as $k => $v) {
3593
                if (\is_string($k) && strtolower($k) === strtolower($type) && \is_array($v)) {
3594
                    return $v;
3595
                }
3596
            }
3597
3598
            return [];
3599
        };
3600
3601
        $docRepo = Container::getDocumentRepository();
3602
        $em = Database::getManager();
3603
3604
        $courseInfo = $this->destination_course_info;
3605
        if (empty($courseInfo) || empty($courseInfo['real_id'])) {
3606
            error_log($logp.'missing courseInfo/real_id');
3607
3608
            return;
3609
        }
3610
3611
        $courseEntity = api_get_course_entity((int) $courseInfo['real_id']);
3612
        if (!$courseEntity) {
3613
            error_log($logp.'api_get_course_entity failed');
3614
3615
            return;
3616
        }
3617
3618
        $sid = property_exists($this, 'current_session_id') ? (int) $this->current_session_id : 0;
3619
        $session = api_get_session_entity($sid);
3620
3621
        $entries = [];
3622
3623
        // A) direct SCORM bucket
3624
        $scormBucket = $getBucket(RESOURCE_SCORM);
3625
        foreach ($scormBucket as $sc) {
3626
            $entries[] = $sc;
3627
        }
3628
3629
        // B) also try LPs that are SCORM
3630
        $lpBucket = $getBucket(RESOURCE_LEARNPATH);
3631
        foreach ($lpBucket as $srcLpId => $lpObj) {
3632
            $lpType = (int) ($lpObj->lp_type ?? $lpObj->type ?? 1);
3633
            if (CLp::SCORM_TYPE === $lpType) {
3634
                $entries[] = (object) [
3635
                    'source_lp_id' => (int) $srcLpId,
3636
                    'lp_id_dest' => (int) ($lpObj->destination_id ?? 0),
3637
                ];
3638
            }
3639
        }
3640
3641
        error_log($logp.'entries='.\count($entries));
3642
        if (empty($entries)) {
3643
            return;
3644
        }
3645
3646
        $lpTop = $docRepo->ensureLearningPathSystemFolder($courseEntity, $session);
3647
3648
        foreach ($entries as $sc) {
3649
            // Locate package (zip or folder → temp zip)
3650
            $srcLpId = (int) ($sc->source_lp_id ?? 0);
3651
            $pkg = $this->findScormPackageForLp($srcLpId);
3652
            if (empty($pkg['zip'])) {
3653
                error_log($logp.'No package (zip/folder) found for a SCORM entry');
3654
3655
                continue;
3656
            }
3657
            $zipAbs = $pkg['zip'];
3658
            $zipTemp = (bool) $pkg['temp'];
3659
3660
            // Map LP title/dest for folder name
3661
            $lpId = 0;
3662
            $lpTitle = 'Untitled';
3663
            if (!empty($sc->lp_id_dest)) {
3664
                $lpId = (int) $sc->lp_id_dest;
3665
            } elseif ($srcLpId && !empty($lpBucket[$srcLpId]->destination_id)) {
3666
                $lpId = (int) $lpBucket[$srcLpId]->destination_id;
3667
            }
3668
            $lpEntity = $lpId ? Container::getLpRepository()->find($lpId) : null;
3669
            if ($lpEntity) {
3670
                $lpTitle = $lpEntity->getTitle() ?: $lpTitle;
3671
            }
3672
3673
            $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string) $lpTitle))) ?: 'Untitled';
3674
            $folderTitleBase = \sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle);
3675
            $folderTitle = $folderTitleBase;
3676
3677
            $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3678
            if ($exists) {
3679
                if (FILE_SKIP === $this->file_option) {
3680
                    error_log($logp."Skip due to folder name collision: '$folderTitle'");
3681
                    if ($zipTemp) {
3682
                        @unlink($zipAbs);
3683
                    }
3684
3685
                    continue;
3686
                }
3687
                if (FILE_RENAME === $this->file_option) {
3688
                    $i = 1;
3689
                    do {
3690
                        $folderTitle = $folderTitleBase.' ('.$i.')';
3691
                        $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3692
                        $i++;
3693
                    } while ($exists);
3694
                }
3695
                if (FILE_OVERWRITE === $this->file_option && $lpEntity) {
3696
                    $docRepo->purgeScormZip($courseEntity, $lpEntity);
3697
                    $em->flush();
3698
                }
3699
            }
3700
3701
            // Upload ZIP under Documents
3702
            $uploaded = new UploadedFile(
3703
                $zipAbs,
3704
                basename($zipAbs),
3705
                'application/zip',
3706
                null,
3707
                true
3708
            );
3709
            $lpFolder = $docRepo->ensureFolder(
3710
                $courseEntity,
3711
                $lpTop,
3712
                $folderTitle,
3713
                ResourceLink::VISIBILITY_DRAFT,
3714
                $session
3715
            );
3716
            $docRepo->createFileInFolder(
3717
                $courseEntity,
3718
                $lpFolder,
3719
                $uploaded,
3720
                \sprintf('SCORM ZIP for LP #%d', $lpId),
3721
                ResourceLink::VISIBILITY_DRAFT,
3722
                $session
3723
            );
3724
            $em->flush();
3725
3726
            if ($zipTemp) {
3727
                @unlink($zipAbs);
3728
            }
3729
            error_log($logp."ZIP stored under folder '$folderTitle'");
3730
        }
3731
    }
3732
3733
    /**
3734
     * Restore learnpaths with minimal dependencies hydration and robust path resolution.
3735
     *
3736
     * @param mixed $session_id
3737
     * @param mixed $respect_base_content
3738
     * @param mixed $destination_course_code
3739
     */
3740
    public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void
3741
    {
3742
        // 0) Ensure we have a resources snapshot (either internal or from the course)
3743
        $this->ensureDepsBagsFromSnapshot();
3744
        $all = $this->getAllResources(); // <- uses snapshot if available
3745
3746
        $docBag = $all[RESOURCE_DOCUMENT] ?? [];
3747
        $quizBag = $all[RESOURCE_QUIZ] ?? [];
3748
        $linkBag = $all[RESOURCE_LINK] ?? [];
3749
        $survBag = $all[RESOURCE_SURVEY] ?? [];
3750
        $workBag = $all[RESOURCE_WORK] ?? [];
3751
        $forumB = $all['forum'] ?? [];
3752
3753
        $this->dlog('LP: deps (after ensure/snapshot)', [
3754
            'document' => \count($docBag),
3755
            'quiz' => \count($quizBag),
3756
            'link' => \count($linkBag),
3757
            'student_publication' => \count($workBag),
3758
            'survey' => \count($survBag),
3759
            'forum' => \count($forumB),
3760
        ]);
3761
3762
        // Quick exit if no LPs selected
3763
        $lpBag = $this->course->resources[RESOURCE_LEARNPATH] ?? [];
3764
        if (empty($lpBag)) {
3765
            $this->dlog('LP: nothing to restore (bag is empty).');
3766
3767
            return;
3768
        }
3769
3770
        // Full snapshot to lookup deps without forcing user selection
3771
        // Must be available BEFORE filtering in the import controller (controller already forwards it).
3772
        $all = $this->getAllResources();
3773
3774
        // Map normalized resource types to bags (no extra validations)
3775
        $type2bags = [
3776
            'document' => ['document', RESOURCE_DOCUMENT],
3777
            'quiz' => ['quiz', RESOURCE_QUIZ],
3778
            'exercise' => ['quiz', RESOURCE_QUIZ],
3779
            'link' => ['link', RESOURCE_LINK],
3780
            'weblink' => ['link', RESOURCE_LINK],
3781
            'url' => ['link', RESOURCE_LINK],
3782
            'work' => ['works', RESOURCE_WORK],
3783
            'student_publication' => ['works', RESOURCE_WORK],
3784
            'survey' => ['survey', RESOURCE_SURVEY],
3785
            'forum' => ['forum', 'forum'],
3786
            // scorm/sco not handled here
3787
        ];
3788
3789
        // ID collectors per dependency kind
3790
        $need = [
3791
            RESOURCE_DOCUMENT => [],
3792
            RESOURCE_QUIZ => [],
3793
            RESOURCE_LINK => [],
3794
            RESOURCE_WORK => [],
3795
            RESOURCE_SURVEY => [],
3796
            'forum' => [],
3797
        ];
3798
3799
        $takeId = static function ($v) {
3800
            if (null === $v || '' === $v) {
3801
                return null;
3802
            }
3803
3804
            return ctype_digit((string) $v) ? (int) $v : null;
3805
        };
3806
3807
        // Collect deps from LP items
3808
        foreach ($lpBag as $srcLpId => $lpWrap) {
3809
            $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : [];
3810
            foreach ($items as $it) {
3811
                $itype = strtolower((string) ($it['item_type'] ?? ''));
3812
                $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? ''));
3813
                $id = $takeId($raw);
3814
3815
                if (null === $id) {
3816
                    continue;
3817
                }
3818
                if (!isset($type2bags[$itype])) {
3819
                    continue;
3820
                }
3821
3822
                [, $bag] = $type2bags[$itype];
3823
                $need[$bag][$id] = true;
3824
            }
3825
        }
3826
3827
        // Collect deps from linked_resources (export helper)
3828
        foreach ($lpBag as $srcLpId => $lpWrap) {
3829
            $linked = \is_array($lpWrap->linked_resources ?? null) ? $lpWrap->linked_resources : [];
3830
            foreach ($linked as $k => $ids) {
3831
                // normalize key to a known bag with $type2bags
3832
                $kk = strtolower($k);
3833
                if (isset($type2bags[$kk])) {
3834
                    [, $bag] = $type2bags[$kk];
3835
                } else {
3836
                    // sometimes exporter uses bag names directly (document/quiz/link/works/survey/forum)
3837
                    $bag = $kk;
3838
                }
3839
3840
                if (!isset($need[$bag])) {
3841
                    continue;
3842
                }
3843
                if (!\is_array($ids)) {
3844
                    continue;
3845
                }
3846
3847
                foreach ($ids as $legacyId) {
3848
                    $id = $takeId($legacyId);
3849
                    if (null !== $id) {
3850
                        $need[$bag][$id] = true;
3851
                    }
3852
                }
3853
            }
3854
        }
3855
3856
        // Build minimal bags from the snapshot using ONLY needed IDs
3857
        $filterBag = static function (array $sourceBag, array $idSet): array {
3858
            if (empty($idSet)) {
3859
                return [];
3860
            }
3861
            $out = [];
3862
            foreach ($idSet as $legacyId => $_) {
3863
                if (isset($sourceBag[$legacyId])) {
3864
                    $out[$legacyId] = $sourceBag[$legacyId];
3865
                }
3866
            }
3867
3868
            return $out;
3869
        };
3870
3871
        // Inject minimal bags only if the selected set didn't include them.
3872
        if (!isset($this->course->resources[RESOURCE_DOCUMENT])) {
3873
            $src = $all[RESOURCE_DOCUMENT] ?? [];
3874
            $this->course->resources[RESOURCE_DOCUMENT] = $filterBag($src, $need[RESOURCE_DOCUMENT]);
3875
        }
3876
        if (!isset($this->course->resources[RESOURCE_QUIZ])) {
3877
            $src = $all[RESOURCE_QUIZ] ?? [];
3878
            $this->course->resources[RESOURCE_QUIZ] = $filterBag($src, $need[RESOURCE_QUIZ]);
3879
            if (!empty($this->course->resources[RESOURCE_QUIZ])
3880
                && !isset($this->course->resources[RESOURCE_QUIZQUESTION])) {
3881
                $this->course->resources[RESOURCE_QUIZQUESTION] =
3882
                    $all[RESOURCE_QUIZQUESTION] ?? ($all['Exercise_Question'] ?? []);
3883
            }
3884
        }
3885
        if (!isset($this->course->resources[RESOURCE_LINK])) {
3886
            $src = $all[RESOURCE_LINK] ?? [];
3887
            $this->course->resources[RESOURCE_LINK] = $filterBag($src, $need[RESOURCE_LINK]);
3888
            if (!isset($this->course->resources[RESOURCE_LINKCATEGORY]) && isset($all[RESOURCE_LINKCATEGORY])) {
3889
                $this->course->resources[RESOURCE_LINKCATEGORY] = $all[RESOURCE_LINKCATEGORY];
3890
            }
3891
        }
3892
        if (!isset($this->course->resources[RESOURCE_WORK])) {
3893
            $src = $all[RESOURCE_WORK] ?? [];
3894
            $this->course->resources[RESOURCE_WORK] = $filterBag($src, $need[RESOURCE_WORK]);
3895
        }
3896
        if (!isset($this->course->resources[RESOURCE_SURVEY])) {
3897
            $src = $all[RESOURCE_SURVEY] ?? [];
3898
            $this->course->resources[RESOURCE_SURVEY] = $filterBag($src, $need[RESOURCE_SURVEY]);
3899
            if (!isset($this->course->resources[RESOURCE_SURVEYQUESTION]) && isset($all[RESOURCE_SURVEYQUESTION])) {
3900
                $this->course->resources[RESOURCE_SURVEYQUESTION] = $all[RESOURCE_SURVEYQUESTION];
3901
            }
3902
        }
3903
        if (!isset($this->course->resources['forum'])) {
3904
            $src = $all['forum'] ?? [];
3905
            $this->course->resources['forum'] = $filterBag($src, $need['forum']);
3906
            // minimal forum support if LP points to forums
3907
            if (!empty($this->course->resources['forum'])) {
3908
                foreach (['Forum_Category', 'thread', 'post'] as $k) {
3909
                    if (!isset($this->course->resources[$k]) && isset($all[$k])) {
3910
                        $this->course->resources[$k] = $all[$k];
3911
                    }
3912
                }
3913
            }
3914
        }
3915
3916
        $this->dlog('LP: minimal deps prepared', [
3917
            'document' => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []),
3918
            'quiz' => \count($this->course->resources[RESOURCE_QUIZ] ?? []),
3919
            'link' => \count($this->course->resources[RESOURCE_LINK] ?? []),
3920
            'student_publication' => \count($this->course->resources[RESOURCE_WORK] ?? []),
3921
            'survey' => \count($this->course->resources[RESOURCE_SURVEY] ?? []),
3922
            'forum' => \count($this->course->resources['forum'] ?? []),
3923
        ]);
3924
3925
        // --- 3) Restore ONLY those minimal bags ---
3926
        if (!empty($this->course->resources[RESOURCE_DOCUMENT])) {
3927
            $this->restore_documents($session_id, false, $destination_course_code);
3928
        }
3929
        if (!empty($this->course->resources[RESOURCE_QUIZ])) {
3930
            $this->restore_quizzes($session_id, false);
3931
        }
3932
        if (!empty($this->course->resources[RESOURCE_LINK])) {
3933
            $this->restore_links($session_id);
3934
        }
3935
        if (!empty($this->course->resources[RESOURCE_WORK])) {
3936
            $this->restore_works($session_id);
3937
        }
3938
        if (!empty($this->course->resources[RESOURCE_SURVEY])) {
3939
            $this->restore_surveys($session_id);
3940
        }
3941
        if (!empty($this->course->resources['forum'])) {
3942
            $this->restore_forums($session_id);
3943
        }
3944
3945
        // --- 4) Create LP + items with resolved paths to new destination iids ---
3946
        $em = Database::getManager();
3947
        $courseEnt = api_get_course_entity($this->destination_course_id);
3948
        $sessionEnt = api_get_session_entity((int) $session_id);
3949
        $lpRepo = Container::getLpRepository();
3950
        $lpItemRepo = Container::getLpItemRepository();
3951
        $docRepo = Container::getDocumentRepository();
3952
3953
        // Optional repos for title fallbacks
3954
        $quizRepo = method_exists(Container::class, 'getQuizRepository') ? Container::getQuizRepository() : null;
3955
        $linkRepo = method_exists(Container::class, 'getLinkRepository') ? Container::getLinkRepository() : null;
3956
        $forumRepo = method_exists(Container::class, 'getForumRepository') ? Container::getForumRepository() : null;
3957
        $surveyRepo = method_exists(Container::class, 'getSurveyRepository') ? Container::getSurveyRepository() : null;
3958
        $workRepo = method_exists(Container::class, 'getStudentPublicationRepository') ? Container::getStudentPublicationRepository() : null;
3959
3960
        $getDst = function (string $bag, $legacyId): int {
3961
            $wrap = $this->course->resources[$bag][$legacyId] ?? null;
3962
3963
            return $wrap && isset($wrap->destination_id) ? (int) $wrap->destination_id : 0;
3964
        };
3965
3966
        $findDocIidByTitle = function (string $title) use ($docRepo, $courseEnt, $sessionEnt): int {
3967
            if ('' === $title) {
3968
                return 0;
3969
            }
3970
3971
            try {
3972
                $hit = $docRepo->findCourseResourceByTitle(
3973
                    $title,
3974
                    $courseEnt->getResourceNode(),
3975
                    $courseEnt,
3976
                    $sessionEnt,
3977
                    api_get_group_entity()
3978
                );
3979
3980
                return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
3981
            } catch (Throwable $e) {
3982
                $this->dlog('LP: doc title lookup failed', ['title' => $title, 'err' => $e->getMessage()]);
3983
3984
                return 0;
3985
            }
3986
        };
3987
3988
        // Generic title finders (defensive: method_exists checks)
3989
        $findByTitle = [
3990
            'quiz' => function (string $title) use ($quizRepo, $courseEnt, $sessionEnt): int {
3991
                if (!$quizRepo || '' === $title) {
3992
                    return 0;
3993
                }
3994
3995
                try {
3996
                    $hit = method_exists($quizRepo, 'findOneByTitleInCourse')
3997
                        ? $quizRepo->findOneByTitleInCourse($title, $courseEnt, $sessionEnt)
3998
                        : $quizRepo->findOneBy(['title' => $title]);
3999
4000
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4001
                } catch (Throwable $e) {
4002
                    return 0;
4003
                }
4004
            },
4005
            'link' => function (string $title) use ($linkRepo, $courseEnt): int {
4006
                if (!$linkRepo || '' === $title) {
4007
                    return 0;
4008
                }
4009
4010
                try {
4011
                    $hit = method_exists($linkRepo, 'findOneByTitleInCourse')
4012
                        ? $linkRepo->findOneByTitleInCourse($title, $courseEnt, null)
4013
                        : $linkRepo->findOneBy(['title' => $title]);
4014
4015
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4016
                } catch (Throwable $e) {
4017
                    return 0;
4018
                }
4019
            },
4020
            'forum' => function (string $title) use ($forumRepo, $courseEnt): int {
4021
                if (!$forumRepo || '' === $title) {
4022
                    return 0;
4023
                }
4024
4025
                try {
4026
                    $hit = method_exists($forumRepo, 'findOneByTitleInCourse')
4027
                        ? $forumRepo->findOneByTitleInCourse($title, $courseEnt, null)
4028
                        : $forumRepo->findOneBy(['forum_title' => $title]) ?? $forumRepo->findOneBy(['title' => $title]);
4029
4030
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4031
                } catch (Throwable $e) {
4032
                    return 0;
4033
                }
4034
            },
4035
            'survey' => function (string $title) use ($surveyRepo, $courseEnt): int {
4036
                if (!$surveyRepo || '' === $title) {
4037
                    return 0;
4038
                }
4039
4040
                try {
4041
                    $hit = method_exists($surveyRepo, 'findOneByTitleInCourse')
4042
                        ? $surveyRepo->findOneByTitleInCourse($title, $courseEnt, null)
4043
                        : $surveyRepo->findOneBy(['title' => $title]);
4044
4045
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4046
                } catch (Throwable $e) {
4047
                    return 0;
4048
                }
4049
            },
4050
            'work' => function (string $title) use ($workRepo, $courseEnt): int {
4051
                if (!$workRepo || '' === $title) {
4052
                    return 0;
4053
                }
4054
4055
                try {
4056
                    $hit = method_exists($workRepo, 'findOneByTitleInCourse')
4057
                        ? $workRepo->findOneByTitleInCourse($title, $courseEnt, null)
4058
                        : $workRepo->findOneBy(['title' => $title]);
4059
4060
                    return $hit && method_exists($hit, 'getIid') ? (int) $hit->getIid() : 0;
4061
                } catch (Throwable $e) {
4062
                    return 0;
4063
                }
4064
            },
4065
        ];
4066
4067
        $resolvePath = function (array $it) use ($getDst, $findDocIidByTitle, $findByTitle): string {
4068
            $itype = strtolower((string) ($it['item_type'] ?? ''));
4069
            $raw = $it['path'] ?? ($it['ref'] ?? ($it['identifierref'] ?? ''));
4070
            $title = trim((string) ($it['title'] ?? ''));
4071
4072
            switch ($itype) {
4073
                case 'document':
4074
                    if (ctype_digit((string) $raw)) {
4075
                        $nid = $getDst(RESOURCE_DOCUMENT, (int) $raw);
4076
4077
                        return $nid ? (string) $nid : '';
4078
                    }
4079
                    if (\is_string($raw) && str_starts_with((string) $raw, 'document/')) {
4080
                        return (string) $raw;
4081
                    }
4082
                    $maybe = $findDocIidByTitle('' !== $title ? $title : (string) $raw);
4083
4084
                    return $maybe ? (string) $maybe : '';
4085
4086
                case 'quiz':
4087
                case 'exercise':
4088
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4089
                    $nid = $id ? $getDst(RESOURCE_QUIZ, $id) : 0;
4090
                    if ($nid) {
4091
                        return (string) $nid;
4092
                    }
4093
                    $nid = $findByTitle['quiz']('' !== $title ? $title : (string) $raw);
4094
4095
                    return $nid ? (string) $nid : '';
4096
4097
                case 'link':
4098
                case 'weblink':
4099
                case 'url':
4100
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4101
                    $nid = $id ? $getDst(RESOURCE_LINK, $id) : 0;
4102
                    if ($nid) {
4103
                        return (string) $nid;
4104
                    }
4105
                    $nid = $findByTitle['link']('' !== $title ? $title : (string) $raw);
4106
4107
                    return $nid ? (string) $nid : '';
4108
4109
                case 'work':
4110
                case 'student_publication':
4111
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4112
                    $nid = $id ? $getDst(RESOURCE_WORK, $id) : 0;
4113
                    if ($nid) {
4114
                        return (string) $nid;
4115
                    }
4116
                    $nid = $findByTitle['work']('' !== $title ? $title : (string) $raw);
4117
4118
                    return $nid ? (string) $nid : '';
4119
4120
                case 'survey':
4121
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4122
                    $nid = $id ? $getDst(RESOURCE_SURVEY, $id) : 0;
4123
                    if ($nid) {
4124
                        return (string) $nid;
4125
                    }
4126
                    $nid = $findByTitle['survey']('' !== $title ? $title : (string) $raw);
4127
4128
                    return $nid ? (string) $nid : '';
4129
4130
                case 'forum':
4131
                    $id = ctype_digit((string) $raw) ? (int) $raw : 0;
4132
                    $nid = $id ? $getDst('forum', $id) : 0;
4133
                    if ($nid) {
4134
                        return (string) $nid;
4135
                    }
4136
                    $nid = $findByTitle['forum']('' !== $title ? $title : (string) $raw);
4137
4138
                    return $nid ? (string) $nid : '';
4139
4140
                default:
4141
                    // keep whatever was exported
4142
                    return (string) $raw;
4143
            }
4144
        };
4145
4146
        foreach ($lpBag as $srcLpId => $lpWrap) {
4147
            $title = (string) ($lpWrap->name ?? $lpWrap->title ?? ('LP '.$srcLpId));
4148
            $desc = (string) ($lpWrap->description ?? '');
4149
            $lpType = (int) ($lpWrap->lp_type ?? $lpWrap->type ?? 1);
4150
4151
            $lp = (new CLp())
4152
                ->setLpType($lpType)
4153
                ->setTitle($title)
4154
                ->setParent($courseEnt)
4155
            ;
4156
4157
            if (method_exists($lp, 'addCourseLink')) {
4158
                $lp->addCourseLink($courseEnt, $sessionEnt);
4159
            }
4160
            if (method_exists($lp, 'setDescription')) {
4161
                $lp->setDescription($desc);
4162
            }
4163
4164
            $lpRepo->createLp($lp);
4165
            $em->flush();
4166
4167
            $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id = (int) $lp->getIid();
4168
4169
            $root = $lpItemRepo->getRootItem($lp->getIid());
4170
            $parents = [0 => $root];
4171
            $items = \is_array($lpWrap->items ?? null) ? $lpWrap->items : [];
4172
            $order = 0;
4173
4174
            foreach ($items as $it) {
4175
                $lvl = (int) ($it['level'] ?? 0);
4176
                $pItem = $parents[$lvl] ?? $root;
4177
4178
                $itype = (string) ($it['item_type'] ?? 'dir');
4179
                $itTitle = (string) ($it['title'] ?? '');
4180
                $path = $resolvePath($it);
4181
4182
                $item = (new CLpItem())
4183
                    ->setLp($lp)
4184
                    ->setParent($pItem)
4185
                    ->setItemType($itype)
4186
                    ->setTitle($itTitle)
4187
                    ->setPath($path)
4188
                    ->setRef((string) ($it['identifier'] ?? ''))
4189
                    ->setDisplayOrder(++$order)
4190
                ;
4191
4192
                if (isset($it['parameters'])) {
4193
                    $item->setParameters((string) $it['parameters']);
4194
                }
4195
                if (isset($it['prerequisite'])) {
4196
                    $item->setPrerequisite((string) $it['prerequisite']);
4197
                }
4198
                if (isset($it['launch_data'])) {
4199
                    $item->setLaunchData((string) $it['launch_data']);
4200
                }
4201
4202
                $lpItemRepo->create($item);
4203
                $parents[$lvl + 1] = $item;
4204
            }
4205
4206
            $em->flush();
4207
4208
            $this->dlog('LP: items created', [
4209
                'lp_iid' => (int) $lp->getIid(),
4210
                'items' => $order,
4211
                'title' => $title,
4212
            ]);
4213
        }
4214
    }
4215
4216
    /**
4217
     * Restore Glossary resources for the destination course.
4218
     *
4219
     * @param mixed $sessionId
4220
     */
4221
    public function restore_glossary($sessionId = 0): void
4222
    {
4223
        if (!$this->course->has_resources(RESOURCE_GLOSSARY)) {
4224
            $this->debug && error_log('COURSE_DEBUG: restore_glossary: no glossary resources in backup.');
4225
4226
            return;
4227
        }
4228
4229
        $em = Database::getManager();
4230
4231
        /** @var CGlossaryRepository $repo */
4232
        $repo = $em->getRepository(CGlossary::class);
4233
4234
        /** @var CourseEntity $courseEntity */
4235
        $courseEntity = api_get_course_entity($this->destination_course_id);
4236
4237
        /** @var SessionEntity|null $sessionEntity */
4238
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4239
4240
        $resources = $this->course->resources;
4241
4242
        foreach ($resources[RESOURCE_GLOSSARY] as $legacyId => $gls) {
4243
            try {
4244
                $title = (string) ($gls->name ?? $gls->title ?? '');
4245
                $desc = (string) ($gls->description ?? '');
4246
                $order = (int) ($gls->display_order ?? 0);
4247
4248
                // Normalize title
4249
                if ('' === $title) {
4250
                    $title = 'Glossary term';
4251
                }
4252
4253
                // HTML rewrite (always)
4254
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[glossary.term]');
4255
4256
                // Look up existing by title in this course + (optional) session
4257
                if (method_exists($repo, 'getResourcesByCourse')) {
4258
                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
4259
                        ->andWhere('resource.title = :title')->setParameter('title', $title)
4260
                        ->setMaxResults(1)
4261
                    ;
4262
                    $existing = $qb->getQuery()->getOneOrNullResult();
4263
                } else {
4264
                    $existing = $repo->findOneBy(['title' => $title]);
4265
                }
4266
4267
                if ($existing instanceof CGlossary) {
4268
                    switch ($this->file_option) {
4269
                        case FILE_SKIP:
4270
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4271
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid();
4272
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: exists title='{$title}' (skip).");
4273
4274
                            continue 2;
4275
4276
                        case FILE_RENAME:
4277
                            // Generate a unique title inside the course/session
4278
                            $base = $title;
4279
                            $try = $base;
4280
                            $i = 1;
4281
                            $isTaken = static function ($repo, $courseEntity, $sessionEntity, $titleTry) {
4282
                                if (method_exists($repo, 'getResourcesByCourse')) {
4283
                                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
4284
                                        ->andWhere('resource.title = :t')->setParameter('t', $titleTry)
4285
                                        ->setMaxResults(1)
4286
                                    ;
4287
4288
                                    return (bool) $qb->getQuery()->getOneOrNullResult();
4289
                                }
4290
4291
                                return (bool) $repo->findOneBy(['title' => $titleTry]);
4292
                            };
4293
                            while ($isTaken($repo, $courseEntity, $sessionEntity, $try)) {
4294
                                $try = $base.' ('.($i++).')';
4295
                            }
4296
                            $title = $try;
4297
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: renaming to '{$title}'.");
4298
4299
                            break;
4300
4301
                        case FILE_OVERWRITE:
4302
                            $em->remove($existing);
4303
                            $em->flush();
4304
                            $this->debug && error_log('COURSE_DEBUG: restore_glossary: existing term deleted (overwrite).');
4305
4306
                            break;
4307
4308
                        default:
4309
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4310
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int) $existing->getIid();
4311
4312
                            continue 2;
4313
                    }
4314
                }
4315
4316
                // Create
4317
                $entity = (new CGlossary())
4318
                    ->setTitle($title)
4319
                    ->setDescription($desc)
4320
                ;
4321
4322
                if (method_exists($entity, 'setParent')) {
4323
                    $entity->setParent($courseEntity);
4324
                }
4325
                if (method_exists($entity, 'addCourseLink')) {
4326
                    $entity->addCourseLink($courseEntity, $sessionEntity);
4327
                }
4328
4329
                if (method_exists($repo, 'create')) {
4330
                    $repo->create($entity);
4331
                } else {
4332
                    $em->persist($entity);
4333
                    $em->flush();
4334
                }
4335
4336
                if ($order && method_exists($entity, 'setDisplayOrder')) {
4337
                    $entity->setDisplayOrder($order);
4338
                    $em->flush();
4339
                }
4340
4341
                $newId = (int) $entity->getIid();
4342
                $this->course->resources[RESOURCE_GLOSSARY][$legacyId] ??= new stdClass();
4343
                $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = $newId;
4344
4345
                $this->debug && error_log("COURSE_DEBUG: restore_glossary: created term iid={$newId}, title='{$title}'");
4346
            } catch (Throwable $e) {
4347
                error_log('COURSE_DEBUG: restore_glossary: failed: '.$e->getMessage());
4348
4349
                continue;
4350
            }
4351
        }
4352
    }
4353
4354
    /**
4355
     * Restore Wiki resources for the destination course.
4356
     *
4357
     * @param mixed $sessionId
4358
     */
4359
    public function restore_wiki($sessionId = 0): void
4360
    {
4361
        if (!$this->course->has_resources(RESOURCE_WIKI)) {
4362
            $this->debug && error_log('COURSE_DEBUG: restore_wiki: no wiki resources in backup.');
4363
4364
            return;
4365
        }
4366
4367
        $em = Database::getManager();
4368
4369
        /** @var CWikiRepository $repo */
4370
        $repo = $em->getRepository(CWiki::class);
4371
4372
        /** @var CourseEntity $courseEntity */
4373
        $courseEntity = api_get_course_entity($this->destination_course_id);
4374
4375
        /** @var SessionEntity|null $sessionEntity */
4376
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4377
4378
        $cid = (int) $this->destination_course_id;
4379
        $sid = (int) ($sessionEntity?->getId() ?? 0);
4380
4381
        $resources = $this->course->resources;
4382
4383
        foreach ($resources[RESOURCE_WIKI] as $legacyId => $w) {
4384
            try {
4385
                $rawTitle = (string) ($w->title ?? $w->name ?? '');
4386
                $reflink = (string) ($w->reflink ?? '');
4387
                $content = (string) ($w->content ?? '');
4388
                $comment = (string) ($w->comment ?? '');
4389
                $progress = (string) ($w->progress ?? '');
4390
                $version = (int) ($w->version ?? 1);
4391
                $groupId = (int) ($w->group_id ?? 0);
4392
                $userId = (int) ($w->user_id ?? api_get_user_id());
4393
4394
                // HTML rewrite
4395
                $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[wiki.page]');
4396
4397
                if ('' === $rawTitle) {
4398
                    $rawTitle = 'Wiki page';
4399
                }
4400
                if ('' === $content) {
4401
                    $content = '<p>&nbsp;</p>';
4402
                }
4403
4404
                // slug maker
4405
                $makeSlug = static function (string $s): string {
4406
                    $s = strtolower(trim($s));
4407
                    $s = preg_replace('/[^\p{L}\p{N}]+/u', '-', $s) ?: '';
4408
                    $s = trim($s, '-');
4409
4410
                    return '' === $s ? 'page' : $s;
4411
                };
4412
                $reflink = '' !== $reflink ? $makeSlug($reflink) : $makeSlug($rawTitle);
4413
4414
                // existence check
4415
                $qbExists = $repo->createQueryBuilder('w')
4416
                    ->select('w.iid')
4417
                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4418
                    ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4419
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4420
                ;
4421
                if ($sid > 0) {
4422
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4423
                } else {
4424
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = 0');
4425
                }
4426
4427
                $exists = (bool) $qbExists->getQuery()->getOneOrNullResult();
4428
4429
                if ($exists) {
4430
                    switch ($this->file_option) {
4431
                        case FILE_SKIP:
4432
                            // map to latest page id
4433
                            $qbLast = $repo->createQueryBuilder('w')
4434
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4435
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4436
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4437
                                ->orderBy('w.version', 'DESC')->setMaxResults(1)
4438
                            ;
4439
                            if ($sid > 0) {
4440
                                $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4441
                            } else {
4442
                                $qbLast->andWhere('COALESCE(w.sessionId,0) = 0');
4443
                            }
4444
4445
                            /** @var CWiki|null $last */
4446
                            $last = $qbLast->getQuery()->getOneOrNullResult();
4447
                            $dest = $last ? (int) ($last->getPageId() ?: $last->getIid()) : 0;
4448
4449
                            $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass();
4450
                            $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = $dest;
4451
4452
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: exists → skip (page_id={$dest}).");
4453
4454
                            continue 2;
4455
4456
                        case FILE_RENAME:
4457
                            $baseSlug = $reflink;
4458
                            $baseTitle = $rawTitle;
4459
                            $i = 1;
4460
                            $trySlug = $baseSlug.'-'.$i;
4461
                            $isTaken = function (string $slug) use ($repo, $cid, $sid, $groupId): bool {
4462
                                $qb = $repo->createQueryBuilder('w')
4463
                                    ->select('w.iid')
4464
                                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4465
                                    ->andWhere('w.reflink = :r')->setParameter('r', $slug)
4466
                                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4467
                                ;
4468
                                if ($sid > 0) {
4469
                                    $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4470
                                } else {
4471
                                    $qb->andWhere('COALESCE(w.sessionId,0) = 0');
4472
                                }
4473
                                $qb->setMaxResults(1);
4474
4475
                                return (bool) $qb->getQuery()->getOneOrNullResult();
4476
                            };
4477
                            while ($isTaken($trySlug)) {
4478
                                $trySlug = $baseSlug.'-'.(++$i);
4479
                            }
4480
                            $reflink = $trySlug;
4481
                            $rawTitle = $baseTitle.' ('.$i.')';
4482
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed to '{$reflink}' / '{$rawTitle}'.");
4483
4484
                            break;
4485
4486
                        case FILE_OVERWRITE:
4487
                            $qbAll = $repo->createQueryBuilder('w')
4488
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
4489
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
4490
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
4491
                            ;
4492
                            if ($sid > 0) {
4493
                                $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
4494
                            } else {
4495
                                $qbAll->andWhere('COALESCE(w.sessionId,0) = 0');
4496
                            }
4497
                            foreach ($qbAll->getQuery()->getResult() as $old) {
4498
                                $em->remove($old);
4499
                            }
4500
                            $em->flush();
4501
                            $this->debug && error_log('COURSE_DEBUG: restore_wiki: removed old pages (overwrite).');
4502
4503
                            break;
4504
4505
                        default:
4506
                            $this->debug && error_log('COURSE_DEBUG: restore_wiki: unknown file_option → skip.');
4507
4508
                            continue 2;
4509
                    }
4510
                }
4511
4512
                // Create new page (one version)
4513
                $wiki = new CWiki();
4514
                $wiki->setCId($cid);
4515
                $wiki->setSessionId($sid);
4516
                $wiki->setGroupId($groupId);
4517
                $wiki->setReflink($reflink);
4518
                $wiki->setTitle($rawTitle);
4519
                $wiki->setContent($content);  // already rewritten
4520
                $wiki->setComment($comment);
4521
                $wiki->setProgress($progress);
4522
                $wiki->setVersion($version > 0 ? $version : 1);
4523
                $wiki->setUserId($userId);
4524
4525
                // timestamps
4526
                try {
4527
                    $dtimeStr = (string) ($w->dtime ?? '');
4528
                    $wiki->setDtime('' !== $dtimeStr ? new DateTime($dtimeStr) : new DateTime('now', new DateTimeZone('UTC')));
4529
                } catch (Throwable) {
4530
                    $wiki->setDtime(new DateTime('now', new DateTimeZone('UTC')));
4531
                }
4532
4533
                $wiki->setIsEditing(0);
4534
                $wiki->setTimeEdit(null);
4535
                $wiki->setHits((int) ($w->hits ?? 0));
4536
                $wiki->setAddlock((int) ($w->addlock ?? 1));
4537
                $wiki->setEditlock((int) ($w->editlock ?? 0));
4538
                $wiki->setVisibility((int) ($w->visibility ?? 1));
4539
                $wiki->setAddlockDisc((int) ($w->addlock_disc ?? 1));
4540
                $wiki->setVisibilityDisc((int) ($w->visibility_disc ?? 1));
4541
                $wiki->setRatinglockDisc((int) ($w->ratinglock_disc ?? 1));
4542
                $wiki->setAssignment((int) ($w->assignment ?? 0));
4543
                $wiki->setScore(isset($w->score) ? (int) $w->score : 0);
4544
                $wiki->setLinksto((string) ($w->linksto ?? ''));
4545
                $wiki->setTag((string) ($w->tag ?? ''));
4546
                $wiki->setUserIp((string) ($w->user_ip ?? api_get_real_ip()));
4547
4548
                if (method_exists($wiki, 'setParent')) {
4549
                    $wiki->setParent($courseEntity);
4550
                }
4551
                if (method_exists($wiki, 'setCreator')) {
4552
                    $wiki->setCreator(api_get_user_entity());
4553
                }
4554
                $groupEntity = $groupId ? api_get_group_entity($groupId) : null;
4555
                if (method_exists($wiki, 'addCourseLink')) {
4556
                    $wiki->addCourseLink($courseEntity, $sessionEntity, $groupEntity);
4557
                }
4558
4559
                $em->persist($wiki);
4560
                $em->flush();
4561
4562
                // Page id
4563
                if (empty($w->page_id)) {
4564
                    $wiki->setPageId((int) $wiki->getIid());
4565
                } else {
4566
                    $pid = (int) $w->page_id;
4567
                    $wiki->setPageId($pid > 0 ? $pid : (int) $wiki->getIid());
4568
                }
4569
                $em->flush();
4570
4571
                // Conf row
4572
                $conf = new CWikiConf();
4573
                $conf->setCId($cid);
4574
                $conf->setPageId((int) $wiki->getPageId());
4575
                $conf->setTask((string) ($w->task ?? ''));
4576
                $conf->setFeedback1((string) ($w->feedback1 ?? ''));
4577
                $conf->setFeedback2((string) ($w->feedback2 ?? ''));
4578
                $conf->setFeedback3((string) ($w->feedback3 ?? ''));
4579
                $conf->setFprogress1((string) ($w->fprogress1 ?? ''));
4580
                $conf->setFprogress2((string) ($w->fprogress2 ?? ''));
4581
                $conf->setFprogress3((string) ($w->fprogress3 ?? ''));
4582
                $conf->setMaxText(isset($w->max_text) ? (int) $w->max_text : 0);
4583
                $conf->setMaxVersion(isset($w->max_version) ? (int) $w->max_version : 0);
4584
4585
                try {
4586
                    $conf->setStartdateAssig(!empty($w->startdate_assig) ? new DateTime((string) $w->startdate_assig) : null);
4587
                } catch (Throwable) {
4588
                    $conf->setStartdateAssig(null);
4589
                }
4590
4591
                try {
4592
                    $conf->setEnddateAssig(!empty($w->enddate_assig) ? new DateTime((string) $w->enddate_assig) : null);
4593
                } catch (Throwable) {
4594
                    $conf->setEnddateAssig(null);
4595
                }
4596
                $conf->setDelayedsubmit(isset($w->delayedsubmit) ? (int) $w->delayedsubmit : 0);
4597
4598
                $em->persist($conf);
4599
                $em->flush();
4600
4601
                $this->course->resources[RESOURCE_WIKI][$legacyId] ??= new stdClass();
4602
                $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = (int) $wiki->getPageId();
4603
4604
                $this->debug && error_log('COURSE_DEBUG: restore_wiki: created page iid='.(int) $wiki->getIid().' page_id='.(int) $wiki->getPageId()." reflink='{$reflink}'");
4605
            } catch (Throwable $e) {
4606
                error_log('COURSE_DEBUG: restore_wiki: failed: '.$e->getMessage());
4607
4608
                continue;
4609
            }
4610
        }
4611
    }
4612
4613
    /**
4614
     * Restore "Thematic" resources for the destination course.
4615
     *
4616
     * @param mixed $sessionId
4617
     */
4618
    public function restore_thematic($sessionId = 0): void
4619
    {
4620
        if (!$this->course->has_resources(RESOURCE_THEMATIC)) {
4621
            $this->debug && error_log('COURSE_DEBUG: restore_thematic: no thematic resources.');
4622
4623
            return;
4624
        }
4625
4626
        $em = Database::getManager();
4627
4628
        /** @var CourseEntity $courseEntity */
4629
        $courseEntity = api_get_course_entity($this->destination_course_id);
4630
4631
        /** @var SessionEntity|null $sessionEntity */
4632
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4633
4634
        $resources = $this->course->resources;
4635
4636
        foreach ($resources[RESOURCE_THEMATIC] as $legacyId => $t) {
4637
            try {
4638
                $p = (array) ($t->params ?? []);
4639
4640
                $title = trim((string) ($p['title'] ?? $p['name'] ?? ''));
4641
                $content = (string) ($p['content'] ?? '');
4642
                $active = (bool) ($p['active'] ?? true);
4643
4644
                if ('' === $title) {
4645
                    $title = 'Thematic';
4646
                }
4647
4648
                // Rewrite embedded HTML so referenced files/images are valid in the new course
4649
                $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[thematic.main]');
4650
4651
                // Create Thematic root
4652
                $thematic = (new CThematic())
4653
                    ->setTitle($title)
4654
                    ->setContent($content)
4655
                    ->setActive($active)
4656
                ;
4657
4658
                // Set ownership and course linkage if available
4659
                if (method_exists($thematic, 'setParent')) {
4660
                    $thematic->setParent($courseEntity);
4661
                }
4662
                if (method_exists($thematic, 'setCreator')) {
4663
                    $thematic->setCreator(api_get_user_entity());
4664
                }
4665
                if (method_exists($thematic, 'addCourseLink')) {
4666
                    $thematic->addCourseLink($courseEntity, $sessionEntity);
4667
                }
4668
4669
                $em->persist($thematic);
4670
                $em->flush();
4671
4672
                // Map new IID back to resources
4673
                $this->course->resources[RESOURCE_THEMATIC][$legacyId] ??= new stdClass();
4674
                $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int) $thematic->getIid();
4675
4676
                // Restore "advances" (timeline slots)
4677
                $advList = (array) ($t->thematic_advance_list ?? []);
4678
                foreach ($advList as $adv) {
4679
                    if (!\is_array($adv)) {
4680
                        $adv = (array) $adv;
4681
                    }
4682
4683
                    $advContent = (string) ($adv['content'] ?? '');
4684
                    // Rewrite HTML inside advance content
4685
                    $advContent = $this->rewriteHtmlForCourse($advContent, (int) $sessionId, '[thematic.advance]');
4686
4687
                    $rawStart = (string) ($adv['start_date'] ?? $adv['startDate'] ?? '');
4688
4689
                    try {
4690
                        $startDate = '' !== $rawStart ? new DateTime($rawStart) : new DateTime('now', new DateTimeZone('UTC'));
4691
                    } catch (Throwable) {
4692
                        $startDate = new DateTime('now', new DateTimeZone('UTC'));
4693
                    }
4694
4695
                    $duration = (int) ($adv['duration'] ?? 1);
4696
                    $doneAdvance = (bool) ($adv['done_advance'] ?? $adv['doneAdvance'] ?? false);
4697
4698
                    $advance = (new CThematicAdvance())
4699
                        ->setThematic($thematic)
4700
                        ->setContent($advContent)
4701
                        ->setStartDate($startDate)
4702
                        ->setDuration($duration)
4703
                        ->setDoneAdvance($doneAdvance)
4704
                    ;
4705
4706
                    // Optional links to attendance/room if present
4707
                    $attId = (int) ($adv['attendance_id'] ?? 0);
4708
                    if ($attId > 0) {
4709
                        $att = $em->getRepository(CAttendance::class)->find($attId);
4710
                        if ($att) {
4711
                            $advance->setAttendance($att);
4712
                        }
4713
                    }
4714
                    $roomId = (int) ($adv['room_id'] ?? 0);
4715
                    if ($roomId > 0) {
4716
                        $room = $em->getRepository(Room::class)->find($roomId);
4717
                        if ($room) {
4718
                            $advance->setRoom($room);
4719
                        }
4720
                    }
4721
4722
                    $em->persist($advance);
4723
                }
4724
4725
                // Restore "plans" (structured descriptions)
4726
                $planList = (array) ($t->thematic_plan_list ?? []);
4727
                foreach ($planList as $pl) {
4728
                    if (!\is_array($pl)) {
4729
                        $pl = (array) $pl;
4730
                    }
4731
4732
                    $plTitle = trim((string) ($pl['title'] ?? ''));
4733
                    if ('' === $plTitle) {
4734
                        $plTitle = 'Plan';
4735
                    }
4736
4737
                    $plDesc = (string) ($pl['description'] ?? '');
4738
                    // Rewrite HTML inside plan description
4739
                    $plDesc = $this->rewriteHtmlForCourse($plDesc, (int) $sessionId, '[thematic.plan]');
4740
4741
                    $descType = (int) ($pl['description_type'] ?? $pl['descriptionType'] ?? 0);
4742
4743
                    $plan = (new CThematicPlan())
4744
                        ->setThematic($thematic)
4745
                        ->setTitle($plTitle)
4746
                        ->setDescription($plDesc)
4747
                        ->setDescriptionType($descType)
4748
                    ;
4749
4750
                    $em->persist($plan);
4751
                }
4752
4753
                // Flush once per thematic (advances + plans)
4754
                $em->flush();
4755
4756
                $this->debug && error_log(
4757
                    'COURSE_DEBUG: restore_thematic: created thematic iid='.(int) $thematic->getIid().
4758
                    ' (advances='.\count($advList).', plans='.\count($planList).')'
4759
                );
4760
            } catch (Throwable $e) {
4761
                error_log('COURSE_DEBUG: restore_thematic: failed: '.$e->getMessage());
4762
4763
                continue;
4764
            }
4765
        }
4766
    }
4767
4768
    /**
4769
     * Restore "Attendance" resources (register + calendar slots).
4770
     *
4771
     * @param mixed $sessionId
4772
     */
4773
    public function restore_attendance($sessionId = 0): void
4774
    {
4775
        if (!$this->course->has_resources(RESOURCE_ATTENDANCE)) {
4776
            $this->debug && error_log('COURSE_DEBUG: restore_attendance: no attendance resources.');
4777
4778
            return;
4779
        }
4780
4781
        $em = Database::getManager();
4782
4783
        /** @var CourseEntity $courseEntity */
4784
        $courseEntity = api_get_course_entity($this->destination_course_id);
4785
4786
        /** @var SessionEntity|null $sessionEntity */
4787
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
4788
4789
        $resources = $this->course->resources;
4790
4791
        foreach ($resources[RESOURCE_ATTENDANCE] as $legacyId => $att) {
4792
            try {
4793
                $p = (array) ($att->params ?? []);
4794
4795
                $title = trim((string) ($p['title'] ?? 'Attendance'));
4796
                $desc = (string) ($p['description'] ?? '');
4797
                $active = (int) ($p['active'] ?? 1);
4798
4799
                // Normalize title
4800
                if ('' === $title) {
4801
                    $title = 'Attendance';
4802
                }
4803
4804
                // Rewrite HTML in description (links to course files, etc.)
4805
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[attendance.main]');
4806
4807
                // Optional grading attributes
4808
                $qualTitle = isset($p['attendance_qualify_title']) ? (string) $p['attendance_qualify_title'] : null;
4809
                $qualMax = (int) ($p['attendance_qualify_max'] ?? 0);
4810
                $weight = (float) ($p['attendance_weight'] ?? 0.0);
4811
                $locked = (int) ($p['locked'] ?? 0);
4812
4813
                // Create attendance entity
4814
                $a = (new CAttendance())
4815
                    ->setTitle($title)
4816
                    ->setDescription($desc)
4817
                    ->setActive($active)
4818
                    ->setAttendanceQualifyTitle($qualTitle ?? '')
4819
                    ->setAttendanceQualifyMax($qualMax)
4820
                    ->setAttendanceWeight($weight)
4821
                    ->setLocked($locked)
4822
                ;
4823
4824
                // Link to course & creator if supported
4825
                if (method_exists($a, 'setParent')) {
4826
                    $a->setParent($courseEntity);
4827
                }
4828
                if (method_exists($a, 'setCreator')) {
4829
                    $a->setCreator(api_get_user_entity());
4830
                }
4831
                if (method_exists($a, 'addCourseLink')) {
4832
                    $a->addCourseLink($courseEntity, $sessionEntity);
4833
                }
4834
4835
                $em->persist($a);
4836
                $em->flush();
4837
4838
                // Map new IID back
4839
                $this->course->resources[RESOURCE_ATTENDANCE][$legacyId] ??= new stdClass();
4840
                $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int) $a->getIid();
4841
4842
                // Restore calendar entries (slots)
4843
                $calList = (array) ($att->attendance_calendar ?? []);
4844
                foreach ($calList as $c) {
4845
                    if (!\is_array($c)) {
4846
                        $c = (array) $c;
4847
                    }
4848
4849
                    // Date/time normalization with fallbacks
4850
                    $rawDt = (string) ($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? '');
4851
4852
                    try {
4853
                        $dt = '' !== $rawDt ? new DateTime($rawDt) : new DateTime('now', new DateTimeZone('UTC'));
4854
                    } catch (Throwable) {
4855
                        $dt = new DateTime('now', new DateTimeZone('UTC'));
4856
                    }
4857
4858
                    $done = (bool) ($c['done_attendance'] ?? $c['doneAttendance'] ?? false);
4859
                    $blocked = (bool) ($c['blocked'] ?? false);
4860
                    $duration = isset($c['duration']) ? (int) $c['duration'] : null;
4861
4862
                    $cal = (new CAttendanceCalendar())
4863
                        ->setAttendance($a)
4864
                        ->setDateTime($dt)
4865
                        ->setDoneAttendance($done)
4866
                        ->setBlocked($blocked)
4867
                        ->setDuration($duration)
4868
                    ;
4869
4870
                    $em->persist($cal);
4871
                    $em->flush();
4872
4873
                    // Optionally attach a group to the calendar slot
4874
                    $groupId = (int) ($c['group_id'] ?? 0);
4875
                    if ($groupId > 0) {
4876
                        try {
4877
                            $repo = $em->getRepository(CAttendanceCalendarRelGroup::class);
4878
                            if (method_exists($repo, 'addGroupToCalendar')) {
4879
                                $repo->addGroupToCalendar((int) $cal->getIid(), $groupId);
4880
                            }
4881
                        } catch (Throwable $e) {
4882
                            $this->debug && error_log('COURSE_DEBUG: restore_attendance: calendar group link skipped: '.$e->getMessage());
4883
                        }
4884
                    }
4885
                }
4886
4887
                // Flush at the end for this attendance
4888
                $em->flush();
4889
                $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int) $a->getIid().' (cal='.\count($calList).')');
4890
            } catch (Throwable $e) {
4891
                error_log('COURSE_DEBUG: restore_attendance: failed: '.$e->getMessage());
4892
4893
                continue;
4894
            }
4895
        }
4896
    }
4897
4898
    /**
4899
     * Restore Student Publications (works) from backup selection.
4900
     * - Honors file policy: FILE_SKIP (1), FILE_RENAME (2), FILE_OVERWRITE (3)
4901
     * - Creates a fresh ResourceNode for new items to avoid unique key collisions
4902
     * - Keeps existing behavior: HTML rewriting, optional calendar event, destination_id mapping
4903
     * - NO entity manager reopen helper (we avoid violations proactively).
4904
     */
4905
    public function restore_works(int $sessionId = 0): void
4906
    {
4907
        if (!$this->course->has_resources(RESOURCE_WORK)) {
4908
            return;
4909
        }
4910
4911
        $em = Database::getManager();
4912
4913
        /** @var CourseEntity $courseEntity */
4914
        $courseEntity = api_get_course_entity($this->destination_course_id);
4915
4916
        /** @var SessionEntity|null $sessionEntity */
4917
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
4918
4919
        /** @var CStudentPublicationRepository $pubRepo */
4920
        $pubRepo = Container::getStudentPublicationRepository();
4921
4922
        // Same-name policy already mapped at controller/restorer level
4923
        $filePolicy = $this->file_option ?? (\defined('FILE_RENAME') ? FILE_RENAME : 2);
4924
4925
        $this->dlog('restore_works: begin', [
4926
            'count' => \count($this->course->resources[RESOURCE_WORK] ?? []),
4927
            'policy' => $filePolicy,
4928
        ]);
4929
4930
        // Helper: generate a unique title within (course, session) scope
4931
        $makeUniqueTitle = function (string $base) use ($pubRepo, $courseEntity, $sessionEntity): string {
4932
            $t = '' !== $base ? $base : 'Work';
4933
            $n = 0;
4934
            $title = $t;
4935
            while (true) {
4936
                $qb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder');
4937
                $exists = $qb
4938
                    ->andWhere('resource.publicationParent IS NULL')
4939
                    ->andWhere('resource.active IN (0,1)')
4940
                    ->setMaxResults(1)
4941
                    ->getQuery()
4942
                    ->getOneOrNullResult()
4943
                ;
4944
                if (!$exists) {
4945
                    return $title;
4946
                }
4947
                $n++;
4948
                $title = $t.' ('.$n.')';
4949
            }
4950
        };
4951
4952
        // Helper: create a fresh ResourceNode for the publication
4953
        $createResourceNode = function (string $title) use ($em, $courseEntity, $sessionEntity) {
4954
            $nodeClass = ResourceNode::class;
4955
            $node = new $nodeClass();
4956
            if (method_exists($node, 'setTitle')) {
4957
                $node->setTitle($title);
4958
            }
4959
            if (method_exists($node, 'setCourse')) {
4960
                $node->setCourse($courseEntity);
4961
            }
4962
            if (method_exists($node, 'addCourseLink')) {
4963
                $node->addCourseLink($courseEntity, $sessionEntity);
4964
            }
4965
            if (method_exists($node, 'setResourceType')) {
4966
                $node->setResourceType('student_publication');
4967
            }
4968
            $em->persist($node);
4969
4970
            // flush is deferred to the publication flush
4971
            return $node;
4972
        };
4973
4974
        foreach ($this->course->resources[RESOURCE_WORK] as $legacyId => $obj) {
4975
            try {
4976
                $p = (array) ($obj->params ?? []);
4977
4978
                $title = trim((string) ($p['title'] ?? 'Work'));
4979
                if ('' === $title) {
4980
                    $title = 'Work';
4981
                }
4982
                $originalTitle = $title;
4983
4984
                $description = (string) ($p['description'] ?? '');
4985
                // HTML rewrite (assignment description)
4986
                $description = $this->rewriteHtmlForCourse($description, (int) $sessionId, '[work.description]');
4987
4988
                $enableQualification = (bool) ($p['enable_qualification'] ?? false);
4989
                $addToCalendar = 1 === (int) ($p['add_to_calendar'] ?? 0);
4990
4991
                $expiresOn = !empty($p['expires_on']) ? new DateTime($p['expires_on']) : null;
4992
                $endsOn = !empty($p['ends_on']) ? new DateTime($p['ends_on']) : null;
4993
4994
                $weight = isset($p['weight']) ? (float) $p['weight'] : 0.0;
4995
                $qualification = isset($p['qualification']) ? (float) $p['qualification'] : 0.0;
4996
                $allowText = (int) ($p['allow_text_assignment'] ?? 0);
4997
                $defaultVisibility = (bool) ($p['default_visibility'] ?? 0);
4998
                $studentMayDelete = (bool) ($p['student_delete_own_publication'] ?? 0);
4999
                $extensions = isset($p['extensions']) ? (string) $p['extensions'] : null;
5000
                $groupCategoryWorkId = (int) ($p['group_category_work_id'] ?? 0);
5001
                $postGroupId = (int) ($p['post_group_id'] ?? 0);
5002
5003
                // Check for existing root folder with same title
5004
                $existingQb = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder');
5005
                $existing = $existingQb
5006
                    ->andWhere('resource.publicationParent IS NULL')
5007
                    ->andWhere('resource.active IN (0,1)')
5008
                    ->setMaxResults(1)
5009
                    ->getQuery()
5010
                    ->getOneOrNullResult()
5011
                ;
5012
5013
                // Apply same-name policy proactively (avoid unique violations)
5014
                if ($existing) {
5015
                    if ($filePolicy === (\defined('FILE_SKIP') ? FILE_SKIP : 1)) {
5016
                        $this->dlog('WORK: skip existing title', ['title' => $title, 'src_id' => $legacyId]);
5017
                        $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5018
                        $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid();
5019
5020
                        continue;
5021
                    }
5022
                    if ($filePolicy === (\defined('FILE_RENAME') ? FILE_RENAME : 2)) {
5023
                        $title = $makeUniqueTitle($title);
5024
                        $existing = null; // force a new one
5025
                    }
5026
                // FILE_OVERWRITE: keep $existing and update below
5027
                } else {
5028
                    // No existing — still ensure uniqueness to avoid slug/node collisions
5029
                    $title = $makeUniqueTitle($title);
5030
                }
5031
5032
                if (!$existing) {
5033
                    // Create NEW publication (folder) + NEW resource node
5034
                    $pub = (new CStudentPublication())
5035
                        ->setTitle($title)
5036
                        ->setDescription($description)  // already rewritten
5037
                        ->setFiletype('folder')
5038
                        ->setContainsFile(0)
5039
                        ->setWeight($weight)
5040
                        ->setQualification($qualification)
5041
                        ->setAllowTextAssignment($allowText)
5042
                        ->setDefaultVisibility($defaultVisibility)
5043
                        ->setStudentDeleteOwnPublication($studentMayDelete)
5044
                        ->setExtensions($extensions)
5045
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
5046
                        ->setPostGroupId($postGroupId)
5047
                    ;
5048
5049
                    if (method_exists($pub, 'setParent')) {
5050
                        $pub->setParent($courseEntity);
5051
                    }
5052
                    if (method_exists($pub, 'setCreator')) {
5053
                        $pub->setCreator(api_get_user_entity());
5054
                    }
5055
                    if (method_exists($pub, 'addCourseLink')) {
5056
                        $pub->addCourseLink($courseEntity, $sessionEntity);
5057
                    }
5058
                    if (method_exists($pub, 'setResourceNode')) {
5059
                        $pub->setResourceNode($createResourceNode($title));
5060
                    }
5061
5062
                    $em->persist($pub);
5063
5064
                    try {
5065
                        $em->flush();
5066
                    } catch (UniqueConstraintViolationException $e) {
5067
                        // As a last resort, rename once and retry quickly (no EM reopen)
5068
                        $this->dlog('WORK: unique violation on create, retry once with renamed title', [
5069
                            'src_id' => $legacyId,
5070
                            'err' => $e->getMessage(),
5071
                        ]);
5072
                        $newTitle = $makeUniqueTitle($title);
5073
                        if (method_exists($pub, 'setTitle')) {
5074
                            $pub->setTitle($newTitle);
5075
                        }
5076
                        if (method_exists($pub, 'setResourceNode')) {
5077
                            $pub->setResourceNode($createResourceNode($newTitle));
5078
                        }
5079
                        $em->persist($pub);
5080
                        $em->flush();
5081
                    }
5082
5083
                    // Create Assignment row
5084
                    $assignment = (new CStudentPublicationAssignment())
5085
                        ->setPublication($pub)
5086
                        ->setEnableQualification($enableQualification || $qualification > 0)
5087
                    ;
5088
                    if ($expiresOn) {
5089
                        $assignment->setExpiresOn($expiresOn);
5090
                    }
5091
                    if ($endsOn) {
5092
                        $assignment->setEndsOn($endsOn);
5093
                    }
5094
5095
                    $em->persist($assignment);
5096
                    $em->flush();
5097
5098
                    // Optional calendar entry
5099
                    if ($addToCalendar) {
5100
                        $eventTitle = \sprintf(get_lang('Handing over of task %s'), $pub->getTitle());
5101
5102
                        $publicationUrl = null;
5103
                        $uuid = $pub->getResourceNode()?->getUuid();
5104
                        if ($uuid) {
5105
                            if (property_exists($this, 'router') && $this->router instanceof RouterInterface) {
5106
                                try {
5107
                                    $publicationUrl = $this->router->generate(
5108
                                        'student_publication_view',
5109
                                        ['uuid' => (string) $uuid],
5110
                                        UrlGeneratorInterface::ABSOLUTE_PATH
5111
                                    );
5112
                                } catch (Throwable) {
5113
                                    $publicationUrl = '/r/student_publication/'.$uuid;
5114
                                }
5115
                            } else {
5116
                                $publicationUrl = '/r/student_publication/'.$uuid;
5117
                            }
5118
                        }
5119
5120
                        $contentBlock = \sprintf(
5121
                            '<div>%s</div> %s',
5122
                            $publicationUrl
5123
                                ? \sprintf('<a href="%s">%s</a>', $publicationUrl, htmlspecialchars($pub->getTitle(), ENT_QUOTES))
5124
                                : htmlspecialchars($pub->getTitle(), ENT_QUOTES),
5125
                            $pub->getDescription()
5126
                        );
5127
                        $contentBlock = $this->rewriteHtmlForCourse($contentBlock, (int) $sessionId, '[work.calendar]');
5128
5129
                        $start = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC'));
5130
                        $end = $expiresOn ? clone $expiresOn : new DateTime('now', new DateTimeZone('UTC'));
5131
5132
                        $color = CCalendarEvent::COLOR_STUDENT_PUBLICATION;
5133
                        if ($colors = api_get_setting('agenda.agenda_colors')) {
5134
                            if (!empty($colors['student_publication'])) {
5135
                                $color = $colors['student_publication'];
5136
                            }
5137
                        }
5138
5139
                        $event = (new CCalendarEvent())
5140
                            ->setTitle($eventTitle)
5141
                            ->setContent($contentBlock)
5142
                            ->setParent($courseEntity)
5143
                            ->setCreator($pub->getCreator())
5144
                            ->addLink(clone $pub->getFirstResourceLink())
5145
                            ->setStartDate($start)
5146
                            ->setEndDate($end)
5147
                            ->setColor($color)
5148
                        ;
5149
5150
                        $em->persist($event);
5151
                        $em->flush();
5152
5153
                        $assignment->setEventCalendarId((int) $event->getIid());
5154
                        $em->flush();
5155
                    }
5156
5157
                    // Map destination for LP path resolution
5158
                    $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5159
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $pub->getIid();
5160
5161
                    $this->dlog('restore_works: created', [
5162
                        'src_id' => (int) $legacyId,
5163
                        'dst_iid' => (int) $pub->getIid(),
5164
                        'title' => $pub->getTitle(),
5165
                    ]);
5166
                } else {
5167
                    // FILE_OVERWRITE: update existing
5168
                    $existing
5169
                        ->setDescription($this->rewriteHtmlForCourse((string) $description, (int) $sessionId, '[work.description.overwrite]'))
5170
                        ->setWeight($weight)
5171
                        ->setQualification($qualification)
5172
                        ->setAllowTextAssignment($allowText)
5173
                        ->setDefaultVisibility($defaultVisibility)
5174
                        ->setStudentDeleteOwnPublication($studentMayDelete)
5175
                        ->setExtensions($extensions)
5176
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
5177
                        ->setPostGroupId($postGroupId)
5178
                    ;
5179
5180
                    // Ensure it has a ResourceNode
5181
                    if (method_exists($existing, 'getResourceNode') && method_exists($existing, 'setResourceNode')) {
5182
                        if (!$existing->getResourceNode()) {
5183
                            $existing->setResourceNode($createResourceNode($existing->getTitle() ?: $originalTitle));
5184
                        }
5185
                    }
5186
5187
                    $em->persist($existing);
5188
                    $em->flush();
5189
5190
                    // Assignment row
5191
                    $assignment = $existing->getAssignment();
5192
                    if (!$assignment) {
5193
                        $assignment = new CStudentPublicationAssignment();
5194
                        $assignment->setPublication($existing);
5195
                        $em->persist($assignment);
5196
                    }
5197
                    $assignment->setEnableQualification($enableQualification || $qualification > 0);
5198
                    $assignment->setExpiresOn($expiresOn);
5199
                    $assignment->setEndsOn($endsOn);
5200
                    if (!$addToCalendar) {
5201
                        $assignment->setEventCalendarId(0);
5202
                    }
5203
                    $em->flush();
5204
5205
                    $this->course->resources[RESOURCE_WORK][$legacyId] ??= new stdClass();
5206
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid();
5207
5208
                    $this->dlog('restore_works: overwritten existing', [
5209
                        'src_id' => (int) $legacyId,
5210
                        'dst_iid' => (int) $existing->getIid(),
5211
                        'title' => $existing->getTitle(),
5212
                    ]);
5213
                }
5214
            } catch (Throwable $e) {
5215
                $this->dlog('restore_works: failed', [
5216
                    'src_id' => (int) $legacyId,
5217
                    'err' => $e->getMessage(),
5218
                ]);
5219
5220
                // Do NOT try to reopen EM here (as requested) — just continue gracefully
5221
                continue;
5222
            }
5223
        }
5224
5225
        $this->dlog('restore_works: end');
5226
    }
5227
5228
    /**
5229
     * Restore the Gradebook structure (categories, evaluations, links).
5230
     * Overwrites destination gradebook for the course/session.
5231
     */
5232
    public function restore_gradebook(int $sessionId = 0): void
5233
    {
5234
        // Only meaningful with OVERWRITE semantics (skip/rename make little sense here)
5235
        if (\in_array($this->file_option, [FILE_SKIP, FILE_RENAME], true)) {
5236
            return;
5237
        }
5238
5239
        if (!$this->course->has_resources(RESOURCE_GRADEBOOK)) {
5240
            $this->dlog('restore_gradebook: no gradebook resources');
5241
5242
            return;
5243
        }
5244
5245
        /** @var EntityManagerInterface $em */
5246
        $em = Database::getManager();
5247
5248
        /** @var Course $courseEntity */
5249
        $courseEntity = api_get_course_entity($this->destination_course_id);
5250
5251
        /** @var SessionEntity|null $sessionEntity */
5252
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
5253
5254
        /** @var User $currentUser */
5255
        $currentUser = api_get_user_entity();
5256
5257
        $catRepo = $em->getRepository(GradebookCategory::class);
5258
5259
        // Clean destination categories when overwriting
5260
        try {
5261
            $existingCats = $catRepo->findBy([
5262
                'course' => $courseEntity,
5263
                'session' => $sessionEntity,
5264
            ]);
5265
            foreach ($existingCats as $cat) {
5266
                $em->remove($cat);
5267
            }
5268
            $em->flush();
5269
            $this->dlog('restore_gradebook: destination cleaned', ['removed' => \count($existingCats)]);
5270
        } catch (Throwable $e) {
5271
            $this->dlog('restore_gradebook: clean failed (continuing)', ['error' => $e->getMessage()]);
5272
        }
5273
5274
        $oldIdToNewCat = [];
5275
5276
        // First pass: create categories
5277
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5278
            $categories = (array) ($gbItem->categories ?? []);
5279
            foreach ($categories as $rawCat) {
5280
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5281
5282
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5283
                $title = (string) ($c['title'] ?? 'Category');
5284
                $desc = (string) ($c['description'] ?? '');
5285
                $weight = (float) ($c['weight'] ?? 0.0);
5286
                $visible = (bool) ($c['visible'] ?? true);
5287
                $locked = (int) ($c['locked'] ?? 0);
5288
5289
                // Rewrite HTML in category description
5290
                $desc = $this->rewriteHtmlForCourse($desc, (int) $sessionId, '[gradebook.category]');
5291
5292
                $new = (new GradebookCategory())
5293
                    ->setCourse($courseEntity)
5294
                    ->setSession($sessionEntity)
5295
                    ->setUser($currentUser)
5296
                    ->setTitle($title)
5297
                    ->setDescription($desc)
5298
                    ->setWeight($weight)
5299
                    ->setVisible($visible)
5300
                    ->setLocked($locked)
5301
                ;
5302
5303
                // Optional flags (mirror legacy fields)
5304
                if (isset($c['generate_certificates'])) {
5305
                    $new->setGenerateCertificates((bool) $c['generate_certificates']);
5306
                }
5307
                if (isset($c['generateCertificates'])) {
5308
                    $new->setGenerateCertificates((bool) $c['generateCertificates']);
5309
                }
5310
                if (isset($c['certificate_validity_period'])) {
5311
                    $new->setCertificateValidityPeriod((int) $c['certificate_validity_period']);
5312
                }
5313
                if (isset($c['certificateValidityPeriod'])) {
5314
                    $new->setCertificateValidityPeriod((int) $c['certificateValidityPeriod']);
5315
                }
5316
                if (isset($c['is_requirement'])) {
5317
                    $new->setIsRequirement((bool) $c['is_requirement']);
5318
                }
5319
                if (isset($c['isRequirement'])) {
5320
                    $new->setIsRequirement((bool) $c['isRequirement']);
5321
                }
5322
                if (isset($c['default_lowest_eval_exclude'])) {
5323
                    $new->setDefaultLowestEvalExclude((bool) $c['default_lowest_eval_exclude']);
5324
                }
5325
                if (isset($c['defaultLowestEvalExclude'])) {
5326
                    $new->setDefaultLowestEvalExclude((bool) $c['defaultLowestEvalExclude']);
5327
                }
5328
                if (\array_key_exists('minimum_to_validate', $c)) {
5329
                    $new->setMinimumToValidate((int) $c['minimum_to_validate']);
5330
                }
5331
                if (\array_key_exists('minimumToValidate', $c)) {
5332
                    $new->setMinimumToValidate((int) $c['minimumToValidate']);
5333
                }
5334
                if (\array_key_exists('gradebooks_to_validate_in_dependence', $c)) {
5335
                    $new->setGradeBooksToValidateInDependence((int) $c['gradebooks_to_validate_in_dependence']);
5336
                }
5337
                if (\array_key_exists('gradeBooksToValidateInDependence', $c)) {
5338
                    $new->setGradeBooksToValidateInDependence((int) $c['gradeBooksToValidateInDependence']);
5339
                }
5340
                if (\array_key_exists('allow_skills_by_subcategory', $c)) {
5341
                    $new->setAllowSkillsBySubcategory((int) $c['allow_skills_by_subcategory']);
5342
                }
5343
                if (\array_key_exists('allowSkillsBySubcategory', $c)) {
5344
                    $new->setAllowSkillsBySubcategory((int) $c['allowSkillsBySubcategory']);
5345
                }
5346
                if (!empty($c['grade_model_id'])) {
5347
                    $gm = $em->find(GradeModel::class, (int) $c['grade_model_id']);
5348
                    if ($gm) {
5349
                        $new->setGradeModel($gm);
5350
                    }
5351
                }
5352
5353
                $em->persist($new);
5354
                $em->flush();
5355
5356
                if ($oldId > 0) {
5357
                    $oldIdToNewCat[$oldId] = $new;
5358
                }
5359
            }
5360
        }
5361
5362
        // Second pass: wire category parents
5363
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5364
            $categories = (array) ($gbItem->categories ?? []);
5365
            foreach ($categories as $rawCat) {
5366
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5367
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5368
                $parentOld = (int) ($c['parent_id'] ?? $c['parentId'] ?? 0);
5369
                if ($oldId > 0 && isset($oldIdToNewCat[$oldId]) && $parentOld > 0 && isset($oldIdToNewCat[$parentOld])) {
5370
                    $cat = $oldIdToNewCat[$oldId];
5371
                    $cat->setParent($oldIdToNewCat[$parentOld]);
5372
                    $em->persist($cat);
5373
                }
5374
            }
5375
        }
5376
        $em->flush();
5377
5378
        // Evaluations and Links per category
5379
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
5380
            $categories = (array) ($gbItem->categories ?? []);
5381
            foreach ($categories as $rawCat) {
5382
                $c = \is_array($rawCat) ? $rawCat : (array) $rawCat;
5383
                $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0);
5384
                if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) {
5385
                    continue;
5386
                }
5387
5388
                $dstCat = $oldIdToNewCat[$oldId];
5389
5390
                // Evaluations (rewrite description HTML)
5391
                foreach ((array) ($c['evaluations'] ?? []) as $rawEval) {
5392
                    $e = \is_array($rawEval) ? $rawEval : (array) $rawEval;
5393
5394
                    $evalDesc = (string) ($e['description'] ?? '');
5395
                    $evalDesc = $this->rewriteHtmlForCourse($evalDesc, (int) $sessionId, '[gradebook.evaluation]');
5396
5397
                    $eval = (new GradebookEvaluation())
5398
                        ->setCourse($courseEntity)
5399
                        ->setCategory($dstCat)
5400
                        ->setTitle((string) ($e['title'] ?? 'Evaluation'))
5401
                        ->setDescription($evalDesc)
5402
                        ->setWeight((float) ($e['weight'] ?? 0.0))
5403
                        ->setMax((float) ($e['max'] ?? 100.0))
5404
                        ->setType((string) ($e['type'] ?? 'manual'))
5405
                        ->setVisible((int) ($e['visible'] ?? 1))
5406
                        ->setLocked((int) ($e['locked'] ?? 0))
5407
                    ;
5408
5409
                    // Optional statistics fields
5410
                    if (isset($e['best_score'])) {
5411
                        $eval->setBestScore((float) $e['best_score']);
5412
                    }
5413
                    if (isset($e['average_score'])) {
5414
                        $eval->setAverageScore((float) $e['average_score']);
5415
                    }
5416
                    if (isset($e['score_weight'])) {
5417
                        $eval->setScoreWeight((float) $e['score_weight']);
5418
                    }
5419
                    if (isset($e['min_score'])) {
5420
                        $eval->setMinScore((float) $e['min_score']);
5421
                    }
5422
5423
                    $em->persist($eval);
5424
                }
5425
5426
                // Links to course tools (resolve destination IID for each)
5427
                foreach ((array) ($c['links'] ?? []) as $rawLink) {
5428
                    $l = \is_array($rawLink) ? $rawLink : (array) $rawLink;
5429
5430
                    $linkType = (int) ($l['type'] ?? $l['link_type'] ?? 0);
5431
                    $legacyRef = (int) ($l['ref_id'] ?? $l['refId'] ?? 0);
5432
                    if ($linkType <= 0 || $legacyRef <= 0) {
5433
                        $this->dlog('restore_gradebook: skipping link (missing type/ref)', $l);
5434
5435
                        continue;
5436
                    }
5437
5438
                    // Map link type → resource bucket, then resolve legacyId → newId
5439
                    $resourceType = $this->gb_guessResourceTypeByLinkType($linkType);
5440
                    $newRefId = $this->gb_resolveDestinationId($resourceType, $legacyRef);
5441
                    if ($newRefId <= 0) {
5442
                        $this->dlog('restore_gradebook: skipping link (no destination id)', ['type' => $linkType, 'legacyRef' => $legacyRef]);
5443
5444
                        continue;
5445
                    }
5446
5447
                    $link = (new GradebookLink())
5448
                        ->setCourse($courseEntity)
5449
                        ->setCategory($dstCat)
5450
                        ->setType($linkType)
5451
                        ->setRefId($newRefId)
5452
                        ->setWeight((float) ($l['weight'] ?? 0.0))
5453
                        ->setVisible((int) ($l['visible'] ?? 1))
5454
                        ->setLocked((int) ($l['locked'] ?? 0))
5455
                    ;
5456
5457
                    // Optional statistics fields
5458
                    if (isset($l['best_score'])) {
5459
                        $link->setBestScore((float) $l['best_score']);
5460
                    }
5461
                    if (isset($l['average_score'])) {
5462
                        $link->setAverageScore((float) $l['average_score']);
5463
                    }
5464
                    if (isset($l['score_weight'])) {
5465
                        $link->setScoreWeight((float) $l['score_weight']);
5466
                    }
5467
                    if (isset($l['min_score'])) {
5468
                        $link->setMinScore((float) $l['min_score']);
5469
                    }
5470
5471
                    $em->persist($link);
5472
                }
5473
5474
                $em->flush();
5475
            }
5476
        }
5477
5478
        $this->dlog('restore_gradebook: done');
5479
    }
5480
5481
    /**
5482
     * Restore course assets (not included in documents).
5483
     */
5484
    public function restore_assets(): void
5485
    {
5486
        if ($this->course->has_resources(RESOURCE_ASSET)) {
5487
            $resources = $this->course->resources;
5488
            $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/';
5489
5490
            foreach ($resources[RESOURCE_ASSET] as $asset) {
5491
                if (is_file($this->course->backup_path.'/'.$asset->path)
5492
                    && is_readable($this->course->backup_path.'/'.$asset->path)
5493
                    && is_dir(\dirname($path.$asset->path))
5494
                    && is_writable(\dirname($path.$asset->path))
5495
                ) {
5496
                    switch ($this->file_option) {
5497
                        case FILE_SKIP:
5498
                            break;
5499
5500
                        case FILE_OVERWRITE:
5501
                            copy(
5502
                                $this->course->backup_path.'/'.$asset->path,
5503
                                $path.$asset->path
5504
                            );
5505
5506
                            break;
5507
                    }
5508
                }
5509
            }
5510
        }
5511
    }
5512
5513
    /**
5514
     * Get all resources from snapshot or live course object.
5515
     *
5516
     * @return array<string,array>
5517
     */
5518
    public function getAllResources(): array
5519
    {
5520
        // Prefer the previously captured snapshot if present; otherwise fall back to current course->resources
5521
        return !empty($this->resources_all_snapshot)
5522
            ? $this->resources_all_snapshot
5523
            : (array) ($this->course->resources ?? []);
5524
    }
5525
5526
    /**
5527
     * Back-fill empty dependency bags from the snapshot into $this->course->resources.
5528
     */
5529
    private function ensureDepsBagsFromSnapshot(): void
5530
    {
5531
        // Read the authoritative set of resources (snapshot or live)
5532
        $all = $this->getAllResources();
5533
5534
        // Reference the course resources by reference to update in place
5535
        $c = &$this->course->resources;
5536
5537
        // Ensure these resource bags exist; if missing/empty, copy them from snapshot
5538
        foreach (['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category'] as $k) {
5539
            $cur = $c[$k] ?? [];
5540
            if ((!\is_array($cur) || 0 === \count($cur)) && !empty($all[$k]) && \is_array($all[$k])) {
5541
                // Back-fill from snapshot to keep dependencies consistent
5542
                $c[$k] = $all[$k];
5543
            }
5544
        }
5545
    }
5546
5547
    /**
5548
     * Rewrite HTML content so legacy course URLs point to destination course documents.
5549
     *
5550
     * Returns the (possibly) rewritten HTML.
5551
     */
5552
    private function rewriteHtmlForCourse(string $html, int $sessionId, string $dbgTag = ''): string
5553
    {
5554
        // Nothing to do if the HTML is empty
5555
        if ('' === $html) {
5556
            return '';
5557
        }
5558
5559
        // Resolve context entities (course/session/group) and repositories
5560
        $course = api_get_course_entity($this->destination_course_id);
5561
        $session = api_get_session_entity((int) $sessionId);
5562
        $group = api_get_group_entity();
5563
        $docRepo = Container::getDocumentRepository();
5564
5565
        // Determine course directory and source root (when importing from a ZIP/package)
5566
        $courseDir = (string) ($this->course->info['path'] ?? '');
5567
        $srcRoot = rtrim((string) ($this->course->backup_path ?? ''), '/');
5568
5569
        // Cache of created folder IIDs per course dir to avoid duplicate folder creation
5570
        if (!isset($this->htmlFoldersByCourseDir[$courseDir])) {
5571
            $this->htmlFoldersByCourseDir[$courseDir] = [];
5572
        }
5573
        $folders = &$this->htmlFoldersByCourseDir[$courseDir];
5574
5575
        // Small debug helper bound to the current dbgTag
5576
        $DBG = function (string $tag, array $ctx = []) use ($dbgTag): void {
5577
            $this->dlog('HTMLRW'.$dbgTag.': '.$tag, $ctx);
5578
        };
5579
5580
        // Ensure a folder chain exists under /document and return parent IID (0 means root)
5581
        $ensureFolder = function (string $relPath) use (&$folders, $course, $session, $DBG) {
5582
            // Ignore empty/root markers
5583
            if ('/' === $relPath || '/document' === $relPath) {
5584
                return 0;
5585
            }
5586
5587
            // Reuse cached IID if we already created/resolved this path
5588
            if (!empty($folders[$relPath])) {
5589
                return (int) $folders[$relPath];
5590
            }
5591
5592
            try {
5593
                // Create the folder via DocumentManager; parent is resolved by the path
5594
                $entity = DocumentManager::addDocument(
5595
                    ['real_id' => $course->getId(), 'code' => method_exists($course, 'getCode') ? $course->getCode() : null],
5596
                    $relPath,
5597
                    'folder',
5598
                    0,
5599
                    basename($relPath),
5600
                    null,
5601
                    0,
5602
                    null,
5603
                    0,
5604
                    (int) ($session?->getId() ?? 0)
5605
                );
5606
5607
                // Cache the created IID if available
5608
                $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0;
5609
                if ($iid > 0) {
5610
                    $folders[$relPath] = $iid;
5611
                }
5612
5613
                return $iid;
5614
            } catch (Throwable $e) {
5615
                // Do not interrupt restore flow if folder creation fails
5616
                $DBG('ensureFolder.error', ['relPath' => $relPath, 'err' => $e->getMessage()]);
5617
5618
                return 0;
5619
            }
5620
        };
5621
5622
        // Only rewrite when we are importing from a package (ZIP) with a known source root
5623
        if ('' !== $srcRoot) {
5624
            try {
5625
                // Build a URL map for all legacy references found in the HTML
5626
                $mapDoc = ChamiloHelper::buildUrlMapForHtmlFromPackage(
5627
                    $html,
5628
                    $courseDir,
5629
                    $srcRoot,
5630
                    $folders,
5631
                    $ensureFolder,
5632
                    $docRepo,
5633
                    $course,
5634
                    $session,
5635
                    $group,
5636
                    (int) $sessionId,
5637
                    (int) $this->file_option,
5638
                    $DBG
5639
                );
5640
5641
                // Rewrite the HTML using both exact (byRel) and basename (byBase) maps
5642
                $rr = ChamiloHelper::rewriteLegacyCourseUrlsWithMap(
5643
                    $html,
5644
                    $courseDir,
5645
                    $mapDoc['byRel'] ?? [],
5646
                    $mapDoc['byBase'] ?? []
5647
                );
5648
5649
                // Log replacement stats for troubleshooting
5650
                $DBG('zip.rewrite', ['replaced' => $rr['replaced'] ?? 0, 'misses' => $rr['misses'] ?? 0]);
5651
5652
                // Return rewritten HTML when available; otherwise the original
5653
                return (string) ($rr['html'] ?? $html);
5654
            } catch (Throwable $e) {
5655
                // Fall back to original HTML if anything fails during mapping/rewrite
5656
                $DBG('zip.error', ['err' => $e->getMessage()]);
5657
5658
                return $html;
5659
            }
5660
        }
5661
5662
        // If no package source root, return the original HTML unchanged
5663
        return $html;
5664
    }
5665
5666
    /**
5667
     * Centralized logger controlled by $this->debug.
5668
     */
5669
    private function dlog(string $message, array $context = []): void
5670
    {
5671
        if (!$this->debug) {
5672
            return;
5673
        }
5674
        $ctx = '';
5675
        if (!empty($context)) {
5676
            try {
5677
                $ctx = ' '.json_encode(
5678
                        $context,
5679
                        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
5680
                    );
5681
            } catch (Throwable $e) {
5682
                $ctx = ' [context_json_failed: '.$e->getMessage().']';
5683
            }
5684
        }
5685
        error_log('COURSE_DEBUG: '.$message.$ctx);
5686
    }
5687
5688
    /**
5689
     * Public setter for the debug flag.
5690
     */
5691
    public function setDebug(?bool $on = true): void
5692
    {
5693
        $this->debug = (bool) $on;
5694
        $this->dlog('Debug flag changed', ['debug' => $this->debug]);
5695
    }
5696
5697
    /**
5698
     * Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored).
5699
     */
5700
    private function gb_resolveDestinationId(?int $type, int $legacyId): int
5701
    {
5702
        if (null === $type) {
5703
            return 0;
5704
        }
5705
        if (!$this->course->has_resources($type)) {
5706
            return 0;
5707
        }
5708
        $bucket = $this->course->resources[$type] ?? [];
5709
        if (!isset($bucket[$legacyId])) {
5710
            return 0;
5711
        }
5712
        $res = $bucket[$legacyId];
5713
        $destId = (int) ($res->destination_id ?? 0);
5714
5715
        return $destId > 0 ? $destId : 0;
5716
    }
5717
5718
    /**
5719
     * Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources.
5720
     */
5721
    private function gb_guessResourceTypeByLinkType(int $linkType): ?int
5722
    {
5723
        return match ($linkType) {
5724
            LINK_EXERCISE => RESOURCE_QUIZ,
5725
            LINK_STUDENTPUBLICATION => RESOURCE_WORK,
5726
            LINK_LEARNPATH => RESOURCE_LEARNPATH,
5727
            LINK_FORUM_THREAD => RESOURCE_FORUMTOPIC,
5728
            LINK_ATTENDANCE => RESOURCE_ATTENDANCE,
5729
            LINK_SURVEY => RESOURCE_SURVEY,
5730
            LINK_HOTPOTATOES => RESOURCE_QUIZ,
5731
            default => null,
5732
        };
5733
    }
5734
5735
    /**
5736
     * Add this setter to forward the full resources snapshot from the controller.
5737
     */
5738
    public function setResourcesAllSnapshot(array $snapshot): void
5739
    {
5740
        // Keep a private property like $this->resources_all_snapshot
5741
        // (declare it if you don't have it: private array $resources_all_snapshot = [];)
5742
        $this->resources_all_snapshot = $snapshot;
5743
        $this->dlog('Restorer: all-resources snapshot injected', [
5744
            'keys' => array_keys($snapshot),
5745
        ]);
5746
    }
5747
5748
    /**
5749
     * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP.
5750
     * Returns absolute path to the temp ZIP or null on error.
5751
     */
5752
    private function zipScormFolder(string $folderAbs): ?string
5753
    {
5754
        $folderAbs = rtrim($folderAbs, '/');
5755
        $manifest = $folderAbs.'/imsmanifest.xml';
5756
        if (!is_file($manifest)) {
5757
            error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs");
5758
5759
            return null;
5760
        }
5761
5762
        $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip';
5763
5764
        try {
5765
            $zip = new ZipFile();
5766
            // Put folder contents at the ZIP root – important for SCORM imports
5767
            $zip->addDirRecursive($folderAbs, '');
5768
            $zip->saveAsFile($tmpZip);
5769
            $zip->close();
5770
        } catch (Throwable $e) {
5771
            error_log('SCORM ZIPPER: Failed to create temp zip: '.$e->getMessage());
5772
5773
            return null;
5774
        }
5775
5776
        if (!is_file($tmpZip) || 0 === filesize($tmpZip)) {
5777
            @unlink($tmpZip);
5778
            error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip");
5779
5780
            return null;
5781
        }
5782
5783
        return $tmpZip;
5784
    }
5785
5786
    /**
5787
     * Find a SCORM package for a given LP.
5788
     * It returns ['zip' => <abs path or null>, 'temp' => true if zip is temporary].
5789
     *
5790
     * Search order:
5791
     *  1) resources[SCORM] entries bound to this LP (zip or path).
5792
     *     - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly.
5793
     *  2) Heuristics: scan typical folders for *.zip
5794
     *  3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder.
5795
     */
5796
    private function findScormPackageForLp(int $srcLpId): array
5797
    {
5798
        $out = ['zip' => null, 'temp' => false];
5799
        $base = rtrim($this->course->backup_path, '/');
5800
5801
        // 1) Direct mapping from SCORM bucket
5802
        if (!empty($this->course->resources[RESOURCE_SCORM]) && \is_array($this->course->resources[RESOURCE_SCORM])) {
5803
            foreach ($this->course->resources[RESOURCE_SCORM] as $sc) {
5804
                $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0;
5805
                $dst = isset($sc->lp_id_dest) ? (int) $sc->lp_id_dest : 0;
5806
                $match = ($src && $src === $srcLpId);
5807
5808
                if (
5809
                    !$match
5810
                    && $dst
5811
                    && !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id)
5812
                ) {
5813
                    $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id);
5814
                }
5815
                if (!$match) {
5816
                    continue;
5817
                }
5818
5819
                $cands = [];
5820
                if (!empty($sc->zip)) {
5821
                    $cands[] = $base.'/'.ltrim((string) $sc->zip, '/');
5822
                }
5823
                if (!empty($sc->path)) {
5824
                    $cands[] = $base.'/'.ltrim((string) $sc->path, '/');
5825
                }
5826
5827
                foreach ($cands as $abs) {
5828
                    if (is_file($abs) && is_readable($abs)) {
5829
                        $out['zip'] = $abs;
5830
                        $out['temp'] = false;
5831
5832
                        return $out;
5833
                    }
5834
                    if (is_dir($abs) && is_readable($abs)) {
5835
                        $tmp = $this->zipScormFolder($abs);
5836
                        if ($tmp) {
5837
                            $out['zip'] = $tmp;
5838
                            $out['temp'] = true;
5839
5840
                            return $out;
5841
                        }
5842
                    }
5843
                }
5844
            }
5845
        }
5846
5847
        // 2) Heuristic: typical folders with *.zip
5848
        foreach (['/scorm', '/document/scorm', '/documents/scorm'] as $dir) {
5849
            $full = $base.$dir;
5850
            if (!is_dir($full)) {
5851
                continue;
5852
            }
5853
            $glob = glob($full.'/*.zip') ?: [];
5854
            if (!empty($glob)) {
5855
                $out['zip'] = $glob[0];
5856
                $out['temp'] = false;
5857
5858
                return $out;
5859
            }
5860
        }
5861
5862
        // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder
5863
        $riiFlags = FilesystemIterator::SKIP_DOTS;
5864
5865
        try {
5866
            $rii = new RecursiveIteratorIterator(
5867
                new RecursiveDirectoryIterator($base, $riiFlags),
5868
                RecursiveIteratorIterator::SELF_FIRST
5869
            );
5870
            foreach ($rii as $f) {
5871
                if ($f->isFile() && 'imsmanifest.xml' === strtolower($f->getFilename())) {
5872
                    $folder = $f->getPath();
5873
                    $tmp = $this->zipScormFolder($folder);
5874
                    if ($tmp) {
5875
                        $out['zip'] = $tmp;
5876
                        $out['temp'] = true;
5877
5878
                        return $out;
5879
                    }
5880
                }
5881
            }
5882
        } catch (Throwable $e) {
5883
            error_log('SCORM FINDER: Recursive scan failed: '.$e->getMessage());
5884
        }
5885
5886
        return $out;
5887
    }
5888
5889
    /**
5890
     * Check if a survey code is available.
5891
     *
5892
     * @param mixed $survey_code
5893
     *
5894
     * @return bool
5895
     */
5896
    public function is_survey_code_available($survey_code)
5897
    {
5898
        $survey_code = (string) $survey_code;
5899
        $surveyRepo = Container::getSurveyRepository();
5900
5901
        try {
5902
            // If a survey with this code exists, it's not available
5903
            $hit = $surveyRepo->findOneBy(['code' => $survey_code]);
5904
5905
            return $hit ? false : true;
5906
        } catch (Throwable $e) {
5907
            // Fallback to "available" on repository failure
5908
            $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage());
5909
5910
            return true;
5911
        }
5912
    }
5913
5914
    /**
5915
     * Resolve absolute filesystem path for an announcement attachment.
5916
     */
5917
    private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string
5918
    {
5919
        // Get the resource node linked to this attachment
5920
        $node = $att->getResourceNode();
5921
        if (!$node) {
5922
            return null; // No node, nothing to resolve
5923
        }
5924
5925
        // Get the first physical resource file
5926
        $file = $node->getFirstResourceFile();
5927
        if (!$file) {
5928
            return null; // No physical file bound
5929
        }
5930
5931
        /** @var ResourceNodeRepository $rnRepo */
5932
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
5933
5934
        // Relative path stored by the repository
5935
        $rel = $rnRepo->getFilename($file);
5936
        if (!$rel) {
5937
            return null; // Missing relative path
5938
        }
5939
5940
        // Compose absolute path inside the project upload base
5941
        $abs = $this->projectUploadBase().$rel;
5942
5943
        // Return only if readable to avoid runtime errors
5944
        return is_readable($abs) ? $abs : null;
5945
    }
5946
5947
    /**
5948
     * Compact dump of resources: keys, per-bag counts and one sample (trimmed).
5949
     */
5950
    private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void
5951
    {
5952
        try {
5953
            $resources = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
5954
5955
            $safe = function ($data): string {
5956
                try {
5957
                    return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]';
5958
                } catch (Throwable $e) {
5959
                    return '[json_exception: '.$e->getMessage().']';
5960
                }
5961
            };
5962
            $short = function ($v, int $max = 200) {
5963
                if (\is_string($v)) {
5964
                    $s = trim($v);
5965
5966
                    return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s;
5967
                }
5968
                if (is_numeric($v) || \is_bool($v) || null === $v) {
5969
                    return $v;
5970
                }
5971
5972
                return '['.\gettype($v).']';
5973
            };
5974
            $sample = function ($item) use ($short, $maxObjFields) {
5975
                $out = [
5976
                    'source_id' => null,
5977
                    'destination_id' => null,
5978
                    'type' => null,
5979
                    'has_obj' => false,
5980
                    'obj_fields' => [],
5981
                    'has_item_props' => false,
5982
                    'extra' => [],
5983
                ];
5984
                if (\is_object($item) || \is_array($item)) {
5985
                    $arr = (array) $item;
5986
                    $out['source_id'] = $arr['source_id'] ?? null;
5987
                    $out['destination_id'] = $arr['destination_id'] ?? null;
5988
                    $out['type'] = $arr['type'] ?? null;
5989
                    $out['has_item_props'] = !empty($arr['item_properties']);
5990
5991
                    $obj = $arr['obj'] ?? null;
5992
                    if (\is_object($obj) || \is_array($obj)) {
5993
                        $out['has_obj'] = true;
5994
                        $objArr = (array) $obj;
5995
                        $fields = [];
5996
                        $i = 0;
5997
                        foreach ($objArr as $k => $v) {
5998
                            if ($i++ >= $maxObjFields) {
5999
                                $fields['__notice'] = 'truncated';
6000
6001
                                break;
6002
                            }
6003
                            $fields[$k] = $short($v);
6004
                        }
6005
                        $out['obj_fields'] = $fields;
6006
                    }
6007
                    foreach (['path', 'title', 'comment'] as $k) {
6008
                        if (isset($arr[$k])) {
6009
                            $out['extra'][$k] = $short($arr[$k]);
6010
                        }
6011
                    }
6012
                } else {
6013
                    $out['extra']['_type'] = \gettype($item);
6014
                }
6015
6016
                return $out;
6017
            };
6018
6019
            $this->dlog('Resources overview', ['keys' => array_keys($resources)]);
6020
6021
            foreach ($resources as $bagName => $bag) {
6022
                if (!\is_array($bag)) {
6023
                    $this->dlog('Bag not an array, skipping', ['bag' => $bagName, 'type' => \gettype($bag)]);
6024
6025
                    continue;
6026
                }
6027
                $count = \count($bag);
6028
                $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]);
6029
6030
                if ($count > 0) {
6031
                    $firstKey = array_key_first($bag);
6032
                    $firstVal = $bag[$firstKey];
6033
                    $s = $sample($firstVal);
6034
                    $s['__first_key'] = $firstKey;
6035
                    $s['__class'] = \is_object($firstVal) ? $firstVal::class : \gettype($firstVal);
6036
                    $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]);
6037
                }
6038
6039
                if (null !== $focusBag && $focusBag === $bagName) {
6040
                    $preview = [];
6041
                    $i = 0;
6042
                    foreach ($bag as $k => $v) {
6043
                        if ($i++ >= 10) {
6044
                            $preview[] = ['__notice' => 'truncated-after-10-items'];
6045
6046
                            break;
6047
                        }
6048
                        $preview[] = ['key' => $k, 'sample' => $sample($v)];
6049
                    }
6050
                    $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]);
6051
                }
6052
            }
6053
        } catch (Throwable $e) {
6054
            $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]);
6055
        }
6056
    }
6057
6058
    /**
6059
     * Get absolute base path where ResourceFiles are stored in the project.
6060
     */
6061
    private function projectUploadBase(): string
6062
    {
6063
        /** @var KernelInterface $kernel */
6064
        $kernel = Container::$container->get('kernel');
6065
6066
        // Resource uploads live under var/upload/resource (Symfony project dir)
6067
        return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource';
6068
    }
6069
6070
    /**
6071
     * Resolve the absolute file path for a CDocument's first ResourceFile, if readable.
6072
     */
6073
    private function resourceFileAbsPathFromDocument(CDocument $doc): ?string
6074
    {
6075
        // Each CDocument references a ResourceNode; bail out if missing
6076
        $node = $doc->getResourceNode();
6077
        if (!$node) {
6078
            return null;
6079
        }
6080
6081
        // Use the first ResourceFile attached to the node
6082
        $file = $node->getFirstResourceFile();
6083
        if (!$file) {
6084
            return null;
6085
        }
6086
6087
        /** @var ResourceNodeRepository $rnRepo */
6088
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
6089
6090
        // Repository provides the relative path for the resource file
6091
        $rel = $rnRepo->getFilename($file);
6092
        if (!$rel) {
6093
            return null;
6094
        }
6095
6096
        // Compose absolute path and validate readability
6097
        $abs = $this->projectUploadBase().$rel;
6098
6099
        return is_readable($abs) ? $abs : null;
6100
    }
6101
6102
    /**
6103
     * Normalize forum keys so internal bags are always available.
6104
     */
6105
    private function normalizeForumKeys(): void
6106
    {
6107
        if (!\is_array($this->course->resources ?? null)) {
6108
            $this->course->resources = [];
6109
6110
            return;
6111
        }
6112
        $r = $this->course->resources;
6113
6114
        // Categories
6115
        if (!isset($r['Forum_Category']) && isset($r['forum_category'])) {
6116
            $r['Forum_Category'] = $r['forum_category'];
6117
        }
6118
6119
        // Forums
6120
        if (!isset($r['forum']) && isset($r['Forum'])) {
6121
            $r['forum'] = $r['Forum'];
6122
        }
6123
6124
        // Topics
6125
        if (!isset($r['thread']) && isset($r['forum_topic'])) {
6126
            $r['thread'] = $r['forum_topic'];
6127
        } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) {
6128
            $r['thread'] = $r['Forum_Thread'];
6129
        }
6130
6131
        // Posts
6132
        if (!isset($r['post']) && isset($r['forum_post'])) {
6133
            $r['post'] = $r['forum_post'];
6134
        } elseif (!isset($r['post']) && isset($r['Forum_Post'])) {
6135
            $r['post'] = $r['Forum_Post'];
6136
        }
6137
6138
        $this->course->resources = $r;
6139
        $this->dlog('Forum keys normalized', [
6140
            'has_Forum_Category' => isset($r['Forum_Category']),
6141
            'forum_count' => isset($r['forum']) && \is_array($r['forum']) ? \count($r['forum']) : 0,
6142
            'thread_count' => isset($r['thread']) && \is_array($r['thread']) ? \count($r['thread']) : 0,
6143
            'post_count' => isset($r['post']) && \is_array($r['post']) ? \count($r['post']) : 0,
6144
        ]);
6145
    }
6146
6147
    /**
6148
     * Reset Doctrine if the EntityManager is closed; otherwise clear it.
6149
     */
6150
    private function resetDoctrineIfClosed(): void
6151
    {
6152
        try {
6153
            // Get the current EntityManager
6154
            $em = Database::getManager();
6155
6156
            if (!$em->isOpen()) {
6157
                // If closed, reset the manager to recover from fatal transaction errors
6158
                $registry = Container::$container->get('doctrine');
6159
                $registry->resetManager();
6160
            } else {
6161
                // If open, just clear to free managed entities and avoid memory leaks
6162
                $em->clear();
6163
            }
6164
        } catch (Throwable $e) {
6165
            // Never break the flow due to maintenance logic
6166
            error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage());
6167
        }
6168
    }
6169
6170
    private function getCourseBackupsBase(): string
6171
    {
6172
        try {
6173
            if (method_exists(CourseArchiver::class, 'getBackupDir')) {
6174
                $dir = rtrim(CourseArchiver::getBackupDir(), '/');
6175
                if ($dir !== '') {
6176
                    return $dir;
6177
                }
6178
            }
6179
        } catch (\Throwable $e) {
6180
        }
6181
6182
        return rtrim(api_get_path(SYS_ARCHIVE_PATH), '/').'/course_backups';
6183
    }
6184
}
6185