Passed
Push — master ( d53566...e05d37 )
by
unknown
17:37 queued 08:43
created

CourseRestorer::restore_thematic()   F

Complexity

Conditions 22
Paths > 20000

Size

Total Lines 146
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 22
eloc 83
c 1
b 1
f 0
nc 121098
nop 1
dl 0
loc 146
rs 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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