CourseRestorer::restore_glossary()   F
last analyzed

Complexity

Conditions 23
Paths 5864

Size

Total Lines 129
Code Lines 77

Duplication

Lines 0
Ratio 0 %

Importance

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