Passed
Push — master ( 1a0efd...d53566 )
by
unknown
17:27 queued 08:40
created

CourseRestorer::restore_gradebook()   F

Complexity

Conditions 53
Paths > 20000

Size

Total Lines 247
Code Lines 150

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 53
eloc 150
c 1
b 1
f 0
nc 1486371602
nop 1
dl 0
loc 247
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
            // scan del directorio oficial de backups del archiver
476
            $scanBase = $this->getCourseBackupsBase();
477
            if (is_dir($scanBase)) {
478
                $cands = glob($scanBase.'/CourseArchiver_*', GLOB_ONLYDIR) ?: [];
479
                if (empty($cands)) {
480
                    $tmp = array_diff(scandir($scanBase) ?: [], ['.', '..']);
481
                    foreach ($tmp as $name) {
482
                        if (strpos($name, 'CourseArchiver_') === 0 && is_dir($scanBase.'/'.$name)) {
483
                            $cands[] = $scanBase.'/'.$name;
484
                        }
485
                    }
486
                }
487
                usort($cands, static function ($a, $b) {
488
                    return (filemtime($b) ?: 0) <=> (filemtime($a) ?: 0);
489
                });
490
                foreach ($cands as $dir) {
491
                    if (is_file($dir.'/course_info.dat') || is_dir($dir.'/document')) {
492
                        $this->dlog('resolveImportRoot: using scanned CourseArchiver', ['dir' => $dir, 'scanBase' => $scanBase]);
493
                        $this->course->resources['__meta']['archiver_root'] = rtrim($dir, '/');
494
495
                        return rtrim($dir, '/');
496
                    }
497
                }
498
            }
499
500
            $this->dlog('resolveImportRoot: no valid import root found, falling back to copy mode');
501
502
            return '';
503
        };
504
505
        $backupRoot = $resolveImportRoot();
506
        $copyMode   = $backupRoot === '';
507
        $srcRoot    = $copyMode ? null : ($backupRoot.'/');
508
509
        $this->dlog('restore_documents: begin', [
510
            'files'   => \count($this->course->resources[RESOURCE_DOCUMENT] ?? []),
511
            'session' => (int) $session_id,
512
            'mode'    => $copyMode ? 'copy' : 'import',
513
            'srcRoot' => $srcRoot,
514
        ]);
515
516
        $DBG = function (string $msg, array $ctx = []): void {
517
            // Keep these concise to avoid noisy logs in production
518
            error_log('[RESTORE:HTMLURL] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx)));
519
        };
520
521
        // Ensure a folder chain exists under Documents (skipping "document" as root)
522
        $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $session_id, $DBG) {
523
            $rel = '/'.ltrim($relPath, '/');
524
            if ('/' === $rel || '' === $rel) {
525
                return 0;
526
            }
527
528
            $parts = array_values(array_filter(explode('/', trim($rel, '/'))));
529
            // Skip "document" root if present
530
            $start = 0;
531
            if (isset($parts[0]) && 'document' === $parts[0]) {
532
                $start = 1;
533
            }
534
535
            $accum    = '';
536
            $parentId = 0;
537
            for ($i = $start; $i < \count($parts); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

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

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

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