Passed
Pull Request — master (#6894)
by
unknown
12:02 queued 03:47
created

CourseRestorer::restore_announcements()   D

Complexity

Conditions 16
Paths 113

Size

Total Lines 135
Code Lines 78

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 16
eloc 78
c 1
b 1
f 0
nc 113
nop 1
dl 0
loc 135
rs 4.8465

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