Passed
Push — master ( a64750...1a0efd )
by
unknown
17:28 queued 07:55
created

CourseRestorer::resetDoctrineIfClosed()   A

Complexity

Conditions 3
Paths 7

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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