Passed
Push — master ( 9df631...0f56b8 )
by
unknown
10:44
created

CourseRestorer::restore_thematic()   F

Complexity

Conditions 26
Paths > 20000

Size

Total Lines 144
Code Lines 98

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 26
eloc 98
nc 1705622
nop 1
dl 0
loc 144
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
namespace Chamilo\CourseBundle\Component\CourseCopy;
6
7
use AllowDynamicProperties;
8
use Chamilo\CoreBundle\Entity\GradebookCategory;
9
use Chamilo\CoreBundle\Entity\GradebookEvaluation;
10
use Chamilo\CoreBundle\Entity\GradebookLink;
11
use Chamilo\CoreBundle\Entity\GradeModel;
12
use Chamilo\CoreBundle\Entity\ResourceLink;
13
use Chamilo\CoreBundle\Entity\Room;
14
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
15
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
16
use Chamilo\CoreBundle\Entity\Tool;
17
use Chamilo\CoreBundle\Framework\Container;
18
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
19
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
20
use Chamilo\CoreBundle\Tool\User;
21
use Chamilo\CourseBundle\Component\CourseCopy\Resources\LearnPathCategory;
22
use Chamilo\CourseBundle\Entity\CAnnouncement;
23
use Chamilo\CourseBundle\Entity\CAnnouncementAttachment;
24
use Chamilo\CourseBundle\Entity\CAttendance;
25
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
26
use Chamilo\CourseBundle\Entity\CAttendanceCalendarRelGroup;
27
use Chamilo\CourseBundle\Entity\CCalendarEvent;
28
use Chamilo\CourseBundle\Entity\CCalendarEventAttachment;
29
use Chamilo\CourseBundle\Entity\CCourseDescription;
30
use Chamilo\CourseBundle\Entity\CDocument;
31
use Chamilo\CourseBundle\Entity\CForum;
32
use Chamilo\CourseBundle\Entity\CForumCategory;
33
use Chamilo\CourseBundle\Entity\CForumPost;
34
use Chamilo\CourseBundle\Entity\CForumThread;
35
use Chamilo\CourseBundle\Entity\CGlossary;
36
use Chamilo\CourseBundle\Entity\CLink;
37
use Chamilo\CourseBundle\Entity\CLinkCategory;
38
use Chamilo\CourseBundle\Entity\CLp;
39
use Chamilo\CourseBundle\Entity\CLpCategory;
40
use Chamilo\CourseBundle\Entity\CLpItem;
41
use Chamilo\CourseBundle\Entity\CQuiz;
42
use Chamilo\CourseBundle\Entity\CQuizAnswer;
43
use Chamilo\CourseBundle\Entity\CQuizQuestion;
44
use Chamilo\CourseBundle\Entity\CQuizQuestionOption;
45
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
46
use Chamilo\CourseBundle\Entity\CStudentPublication;
47
use Chamilo\CourseBundle\Entity\CStudentPublicationAssignment;
48
use Chamilo\CourseBundle\Entity\CSurvey;
49
use Chamilo\CourseBundle\Entity\CSurveyQuestion;
50
use Chamilo\CourseBundle\Entity\CSurveyQuestionOption;
51
use Chamilo\CourseBundle\Entity\CThematic;
52
use Chamilo\CourseBundle\Entity\CThematicAdvance;
53
use Chamilo\CourseBundle\Entity\CThematicPlan;
54
use Chamilo\CourseBundle\Entity\CTool;
55
use Chamilo\CourseBundle\Entity\CToolIntro;
56
use Chamilo\CourseBundle\Entity\CWiki;
57
use Chamilo\CourseBundle\Entity\CWikiConf;
58
use Chamilo\CourseBundle\Repository\CGlossaryRepository;
59
use Chamilo\CourseBundle\Repository\CStudentPublicationRepository;
60
use Chamilo\CourseBundle\Repository\CWikiRepository;
61
use CourseManager;
62
use Database;
63
use Doctrine\Common\Collections\ArrayCollection;
64
use Doctrine\ORM\EntityManagerInterface;
65
use DocumentManager;
66
use GroupManager;
67
use learnpath;
68
use PhpZip\ZipFile;
69
use Symfony\Component\HttpFoundation\File\UploadedFile;
70
use Symfony\Component\HttpKernel\KernelInterface;
71
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
72
use Symfony\Component\Routing\RouterInterface;
73
74
/**
75
 * Class CourseRestorer.
76
 *
77
 * Class to restore items from a course object to a Chamilo-course
78
 *
79
 * @author Bart Mollet <[email protected]>
80
 * @author Julio Montoya <[email protected]> Several fixes/improvements
81
 */
82
#[AllowDynamicProperties]
83
class CourseRestorer
84
{
85
    /** Debug flag (default: true). Toggle with setDebug(). */
86
    private bool $debug = true;
87
88
    /**
89
     * The course-object.
90
     */
91
    public $course;
92
    public $destination_course_info;
93
94
    /** What to do with files with same name (FILE_SKIP, FILE_RENAME, FILE_OVERWRITE). */
95
    public $file_option;
96
    public $set_tools_invisible_by_default;
97
    public $skip_content;
98
99
    /** Restore order (keep existing order; docs first). */
100
    public $tools_to_restore = [
101
        'documents',
102
        'announcements',
103
        'attendance',
104
        'course_descriptions',
105
        'events',
106
        'forum_category',
107
        'forums',
108
        // 'forum_topics',
109
        'glossary',
110
        'quizzes',
111
        'test_category',
112
        'links',
113
        'works',
114
        'surveys',
115
        'learnpath_category',
116
        'learnpaths',
117
        'scorm_documents',
118
        'tool_intro',
119
        'thematic',
120
        'wiki',
121
        'gradebook',
122
        'assets',
123
    ];
124
125
    /** Setting per tool */
126
    public $tool_copy_settings = [];
127
128
    /** If true adds the text "copy" in the title of an item (only for LPs right now). */
129
    public $add_text_in_items = false;
130
131
    public $destination_course_id;
132
    public bool $copySessionContent = false;
133
134
    /** Optional course origin id (legacy). */
135
    private $course_origin_id = null;
136
137
    /** First teacher (owner) used for forums/posts. */
138
    private $first_teacher_id = 0;
139
140
    /** Destination course entity cache. */
141
    private $destination_course_entity;
142
143
    /**
144
     * Centralized logger controlled by $this->debug.
145
     */
146
    private function dlog(string $message, array $context = []): void
147
    {
148
        if (!$this->debug) {
149
            return;
150
        }
151
        $ctx = '';
152
        if (!empty($context)) {
153
            try {
154
                $ctx = ' ' . json_encode(
155
                        $context,
156
                        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
157
                    );
158
            } catch (\Throwable $e) {
159
                $ctx = ' [context_json_failed: '.$e->getMessage().']';
160
            }
161
        }
162
        error_log('COURSE_DEBUG: '.$message.$ctx);
163
    }
164
165
    /**
166
     * Public setter for the debug flag.
167
     */
168
    public function setDebug(?bool $on = true): void
169
    {
170
        $this->debug = (bool) $on;
171
        $this->dlog('Debug flag changed', ['debug' => $this->debug]);
172
    }
173
174
    /**
175
     * CourseRestorer constructor.
176
     *
177
     * @param Course $course
178
     */
179
    public function __construct($course)
180
    {
181
        // Read env constant/course hint if present
182
        if (defined('COURSE_RESTORER_DEBUG')) {
183
            $this->debug = (bool) constant('COURSE_RESTORER_DEBUG');
184
        }
185
186
        $this->course = $course;
187
        $courseInfo = api_get_course_info($this->course->code);
188
        $this->course_origin_id = !empty($courseInfo) ? $courseInfo['real_id'] : null;
189
190
        $this->file_option = FILE_RENAME;
191
        $this->set_tools_invisible_by_default = false;
192
        $this->skip_content = [];
193
194
        $this->dlog('Ctor: initial course info', [
195
            'course_code' => $this->course->code ?? null,
196
            'origin_id'   => $this->course_origin_id,
197
            'has_resources' => is_array($this->course->resources ?? null),
198
            'resource_keys' => array_keys((array) ($this->course->resources ?? [])),
199
        ]);
200
    }
201
202
    /**
203
     * Set the file-option.
204
     *
205
     * @param int $option FILE_SKIP, FILE_RENAME or FILE_OVERWRITE
206
     */
207
    public function set_file_option($option = FILE_OVERWRITE)
208
    {
209
        $this->file_option = $option;
210
        $this->dlog('File option set', ['file_option' => $this->file_option]);
211
    }
212
213
    /**
214
     * @param bool $status
215
     */
216
    public function set_add_text_in_items($status)
217
    {
218
        $this->add_text_in_items = $status;
219
    }
220
221
    /**
222
     * @param array $array
223
     */
224
    public function set_tool_copy_settings($array)
225
    {
226
        $this->tool_copy_settings = $array;
227
    }
228
229
    /** Normalize forum keys so internal bags are always available. */
230
    private function normalizeForumKeys(): void
231
    {
232
        if (!is_array($this->course->resources ?? null)) {
233
            $this->course->resources = [];
234
            return;
235
        }
236
        $r = $this->course->resources;
237
238
        // Categories
239
        if (!isset($r['Forum_Category']) && isset($r['forum_category'])) {
240
            $r['Forum_Category'] = $r['forum_category'];
241
        }
242
243
        // Forums
244
        if (!isset($r['forum']) && isset($r['Forum'])) {
245
            $r['forum'] = $r['Forum'];
246
        }
247
248
        // Topics
249
        if (!isset($r['thread']) && isset($r['forum_topic'])) {
250
            $r['thread'] = $r['forum_topic'];
251
        } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) {
252
            $r['thread'] = $r['Forum_Thread'];
253
        }
254
255
        // Posts
256
        if (!isset($r['post']) && isset($r['forum_post'])) {
257
            $r['post'] = $r['forum_post'];
258
        } elseif (!isset($r['post']) && isset($r['Forum_Post'])) {
259
            $r['post'] = $r['Forum_Post'];
260
        }
261
262
        $this->course->resources = $r;
263
        $this->dlog('Forum keys normalized', [
264
            'has_Forum_Category' => isset($r['Forum_Category']),
265
            'forum_count'        => isset($r['forum']) && is_array($r['forum']) ? count($r['forum']) : 0,
266
            'thread_count'       => isset($r['thread']) && is_array($r['thread']) ? count($r['thread']) : 0,
267
            'post_count'         => isset($r['post']) && is_array($r['post']) ? count($r['post']) : 0,
268
        ]);
269
    }
270
271
    private function resetDoctrineIfClosed(): void
272
    {
273
        try {
274
            $em = \Database::getManager();
275
            if (!$em->isOpen()) {
276
                $registry = Container::$container->get('doctrine');
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

276
                /** @scrutinizer ignore-call */ 
277
                $registry = Container::$container->get('doctrine');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
277
                $registry->resetManager();
278
            } else {
279
                $em->clear();
280
            }
281
        } catch (\Throwable $e) {
282
            error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage());
283
        }
284
    }
285
286
    /**
287
     * Entry point.
288
     */
289
    public function restore(
290
        $destination_course_code = '',
291
        $session_id = 0,
292
        $update_course_settings = false,
293
        $respect_base_content = false
294
    ) {
295
        $this->dlog('Restore() called', [
296
            'destination_code'     => $destination_course_code,
297
            'session_id'           => (int) $session_id,
298
            'update_course_settings' => (bool) $update_course_settings,
299
            'respect_base_content' => (bool) $respect_base_content,
300
        ]);
301
302
        // Resolve destination course
303
        $course_info = $destination_course_code === ''
304
            ? api_get_course_info()
305
            : api_get_course_info($destination_course_code);
306
307
        if (empty($course_info) || empty($course_info['real_id'])) {
308
            $this->dlog('Destination course not resolved or missing real_id', ['course_info' => $course_info]);
309
            return false;
310
        }
311
312
        $this->destination_course_info  = $course_info;
313
        $this->destination_course_id    = (int) $course_info['real_id'];
314
        $this->destination_course_entity = api_get_course_entity($this->destination_course_id);
315
316
        // Resolve teacher for forum/thread/post ownership
317
        $this->first_teacher_id = api_get_user_id();
318
        $teacher_list = CourseManager::get_teacher_list_from_course_code($course_info['code']);
319
        if (!empty($teacher_list)) {
320
            foreach ($teacher_list as $t) { $this->first_teacher_id = (int) $t['user_id']; break; }
321
        }
322
323
        if (empty($this->course)) {
324
            $this->dlog('No source course found');
325
            return false;
326
        }
327
328
        // Encoding detection/normalization
329
        if (empty($this->course->encoding)) {
330
            $sample_text = $this->course->get_sample_text()."\n";
331
            $lines = explode("\n", $sample_text);
332
            foreach ($lines as $k => $line) {
333
                if (api_is_valid_ascii($line)) { unset($lines[$k]); }
334
            }
335
            $sample_text = implode("\n", $lines);
336
            $this->course->encoding = api_detect_encoding($sample_text, $course_info['language']);
337
        }
338
        $this->course->to_system_encoding();
339
        $this->dlog('Encoding resolved', ['encoding' => $this->course->encoding ?? '']);
340
341
        // Normalize forum bags
342
        $this->normalizeForumKeys();
343
344
        // Dump a compact view of the resource bags before restoring
345
        $this->debug_course_resources_simple(null);
346
347
        // Restore tools
348
        foreach ($this->tools_to_restore as $tool) {
349
            $fn = 'restore_'.$tool;
350
            if (method_exists($this, $fn)) {
351
                $this->dlog('Starting tool restore', ['tool' => $tool]);
352
                try {
353
                    $this->{$fn}($session_id, $respect_base_content, $destination_course_code);
354
                } catch (\Throwable $e) {
355
                    $this->dlog('Tool restore failed with exception', [
356
                        'tool' => $tool,
357
                        'error' => $e->getMessage(),
358
                    ]);
359
                    $this->resetDoctrineIfClosed();
360
                }
361
                $this->dlog('Finished tool restore', ['tool' => $tool]);
362
            } else {
363
                $this->dlog('Restore method not found for tool (skipping)', ['tool' => $tool]);
364
            }
365
        }
366
367
        // Optionally restore safe course settings
368
        if ($update_course_settings) {
369
            $this->dlog('Restoring course settings');
370
            $this->restore_course_settings($destination_course_code);
371
        }
372
373
        $this->dlog('Restore() finished', [
374
            'destination_course_id' => $this->destination_course_id,
375
        ]);
376
377
        return null;
378
    }
379
380
    /**
381
     * Restore only harmless course settings (Chamilo 2 entity-safe).
382
     */
383
    public function restore_course_settings(string $destination_course_code = ''): void
384
    {
385
        $this->dlog('restore_course_settings() called');
386
387
        $courseEntity = null;
388
389
        if ($destination_course_code !== '') {
390
            $courseEntity = Container::getCourseRepository()->findOneByCode($destination_course_code);
391
        } else {
392
            if (!empty($this->destination_course_id)) {
393
                $courseEntity = api_get_course_entity((int) $this->destination_course_id);
394
            } else {
395
                $info = api_get_course_info();
396
                if (!empty($info['real_id'])) {
397
                    $courseEntity = api_get_course_entity((int) $info['real_id']);
398
                }
399
            }
400
        }
401
402
        if (!$courseEntity) {
403
            $this->dlog('No destination course entity found, skipping settings restore');
404
            return;
405
        }
406
407
        $src = $this->course->info ?? [];
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
408
409
        if (!empty($src['language'])) {
410
            $courseEntity->setCourseLanguage((string) $src['language']);
411
        }
412
        if (isset($src['visibility']) && $src['visibility'] !== '') {
413
            $courseEntity->setVisibility((int) $src['visibility']);
414
        }
415
        if (array_key_exists('department_name', $src)) {
416
            $courseEntity->setDepartmentName((string) $src['department_name']);
417
        }
418
        if (array_key_exists('department_url', $src)) {
419
            $courseEntity->setDepartmentUrl((string) $src['department_url']);
420
        }
421
        if (!empty($src['category_id'])) {
422
            $catRepo = Container::getCourseCategoryRepository();
423
            $cat = $catRepo?->find((int) $src['category_id']);
424
            if ($cat) {
425
                $courseEntity->setCategories(new ArrayCollection([$cat]));
426
            }
427
        }
428
        if (array_key_exists('subscribe_allowed', $src)) {
429
            $courseEntity->setSubscribe((bool) $src['subscribe_allowed']);
430
        }
431
        if (array_key_exists('unsubscribe', $src)) {
432
            $courseEntity->setUnsubscribe((bool) $src['unsubscribe']);
433
        }
434
435
        $em = Database::getManager();
436
        $em->persist($courseEntity);
437
        $em->flush();
438
439
        $this->dlog('Course settings restored');
440
    }
441
442
    private function projectUploadBase(): string
443
    {
444
        /** @var KernelInterface $kernel */
445
        $kernel = Container::$container->get('kernel');
446
        return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource';
447
    }
448
449
    private function resourceFileAbsPathFromDocument(CDocument $doc): ?string
450
    {
451
        $node = $doc->getResourceNode();
452
        if (!$node) return null;
453
454
        $file = $node->getFirstResourceFile();
455
        if (!$file) return null;
456
457
        /** @var ResourceNodeRepository $rnRepo */
458
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
459
        $rel    = $rnRepo->getFilename($file);
460
        if (!$rel) return null;
461
462
        $abs = $this->projectUploadBase().$rel;
463
        return is_readable($abs) ? $abs : null;
464
    }
465
466
    /**
467
     * Restore documents.
468
     */
469
    public function restore_documents($session_id = 0, $respect_base_content = false, $destination_course_code = '')
470
    {
471
        if (!$this->course->has_resources(RESOURCE_DOCUMENT)) {
472
            $this->dlog('restore_documents: no document resources');
473
            return;
474
        }
475
476
        $courseInfo   = $this->destination_course_info;
477
        $docRepo      = Container::getDocumentRepository();
478
        $courseEntity = api_get_course_entity($courseInfo['real_id']);
479
        $session      = api_get_session_entity((int)$session_id);
480
        $group        = api_get_group_entity(0);
481
482
        $copyMode = empty($this->course->backup_path);
483
        $srcRoot  = $copyMode ? null : rtrim((string)$this->course->backup_path, '/').'/';
484
485
        $this->dlog('restore_documents: begin', [
486
            'files'   => count($this->course->resources[RESOURCE_DOCUMENT] ?? []),
487
            'session' => (int) $session_id,
488
            'mode'    => $copyMode ? 'copy' : 'import',
489
            'srcRoot' => $srcRoot,
490
        ]);
491
492
        // 1) folders
493
        $folders = [];
494
        foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) {
495
            if ($item->file_type !== FOLDER) { continue; }
496
497
            $rel = '/'.ltrim(substr($item->path, 8), '/');
498
            if ($rel === '/') { continue; }
499
500
            $parts = array_values(array_filter(explode('/', $rel)));
501
            $accum = '';
502
            $parentId = 0;
503
504
            foreach ($parts as $i => $seg) {
505
                $accum .= '/'.$seg;
506
                if (isset($folders[$accum])) { $parentId = $folders[$accum]; continue; }
507
508
                $parentResource = $parentId ? $docRepo->find($parentId) : $courseEntity;
509
                $title = ($i === count($parts)-1) ? ($item->title ?: $seg) : $seg;
510
511
                $existing = $docRepo->findCourseResourceByTitle(
512
                    $title, $parentResource->getResourceNode(), $courseEntity, $session, $group
0 ignored issues
show
Bug introduced by
The method getResourceNode() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

512
                    $title, $parentResource->/** @scrutinizer ignore-call */ getResourceNode(), $courseEntity, $session, $group

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
513
                );
514
515
                if ($existing) {
516
                    $iid = method_exists($existing,'getIid') ? $existing->getIid() : 0;
517
                    $this->dlog('restore_documents: reuse folder', ['title' => $title, 'iid' => $iid]);
518
                } else {
519
                    $entity = DocumentManager::addDocument(
520
                        ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']],
521
                        $accum, 'folder', 0, $title, null, 0, null, 0, (int)$session_id, 0, false, '', $parentId, ''
522
                    );
523
                    $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0;
524
                    $this->dlog('restore_documents: created folder', ['title' => $title, 'iid' => $iid]);
525
                }
526
527
                $folders[$accum] = $iid;
528
                if ($i === count($parts)-1) {
529
                    $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid;
530
                }
531
                $parentId = $iid;
532
            }
533
        }
534
535
        // 2) files
536
        foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) {
537
            if ($item->file_type !== DOCUMENT) { continue; }
538
539
            $srcPath  = null;
540
            $rawTitle = $item->title ?: basename((string)$item->path);
541
            $ext      = strtolower(pathinfo($rawTitle, PATHINFO_EXTENSION));
542
            $isHtml   = in_array($ext, ['html','htm'], true);
543
544
            if ($copyMode) {
545
                $srcDoc = null;
546
                if (!empty($item->source_id)) {
547
                    $srcDoc = $docRepo->find((int)$item->source_id);
548
                }
549
                if (!$srcDoc) {
550
                    $this->dlog('restore_documents: source CDocument not found by source_id', ['source_id' => $item->source_id ?? null]);
551
                    continue;
552
                }
553
                $srcPath = $this->resourceFileAbsPathFromDocument($srcDoc);
554
                if (!$srcPath) {
555
                    $this->dlog('restore_documents: source file not readable from ResourceFile', ['source_id' => (int)$item->source_id]);
556
                    continue;
557
                }
558
            } else {
559
                $srcPath = $srcRoot.$item->path;
560
                if (!is_file($srcPath) || !is_readable($srcPath)) {
561
                    $this->dlog('restore_documents: source file not found/readable', ['src' => $srcPath]);
562
                    continue;
563
                }
564
            }
565
566
            $rel       = '/'.ltrim(substr($item->path, 8), '/');
567
            $parentRel = rtrim(dirname($rel), '/');
568
            $parentId  = $folders[$parentRel] ?? 0;
569
            $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity;
570
571
            $baseTitle  = $rawTitle;
572
            $finalTitle = $baseTitle;
573
574
            $findExisting = function($t) use ($docRepo,$parentRes,$courseEntity,$session,$group){
575
                $e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group);
0 ignored issues
show
Bug introduced by
The method getResourceNode() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

575
                $e = $docRepo->findCourseResourceByTitle($t, $parentRes->/** @scrutinizer ignore-call */ getResourceNode(), $courseEntity, $session, $group);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
576
                return $e && method_exists($e,'getIid') ? $e->getIid() : null;
577
            };
578
579
            $existsIid = $findExisting($finalTitle);
580
            if ($existsIid) {
581
                $this->dlog('restore_documents: collision', ['title' => $finalTitle, 'policy' => $this->file_option]);
582
                if ($this->file_option === FILE_SKIP) {
583
                    $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $existsIid;
584
                    continue;
585
                }
586
                $pi   = pathinfo($baseTitle);
587
                $name = $pi['filename'] ?? $baseTitle;
588
                $ext2 = isset($pi['extension']) && $pi['extension'] !== '' ? '.'.$pi['extension'] : '';
589
                $i=1;
590
                while ($findExisting($finalTitle)) { $finalTitle = $name.'_'.$i.$ext2; $i++; }
591
            }
592
593
            $content  = '';
594
            $realPath = '';
595
            if ($isHtml) {
596
                $raw = @file_get_contents($srcPath) ?: '';
597
                if (defined('UTF8_CONVERT') && UTF8_CONVERT) { $raw = utf8_encode($raw); }
598
                $content = DocumentManager::replaceUrlWithNewCourseCode(
599
                    $raw,
600
                    $this->course->code,
601
                    $this->course->destination_path,
602
                    $this->course->backup_path,
603
                    $this->course->info['path']
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
604
                );
605
            } else {
606
                $realPath = $srcPath;
607
            }
608
609
            try {
610
                $entity = DocumentManager::addDocument(
611
                    ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']],
612
                    $rel,
613
                    'file',
614
                    (int)($item->size ?? 0),
615
                    $finalTitle,
616
                    $item->comment ?? '',
617
                    0,
618
                    null,
619
                    0,
620
                    (int)$session_id,
621
                    0,
622
                    false,
623
                    $content,
624
                    $parentId,
625
                    $realPath
626
                );
627
                $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0;
628
                $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid;
629
                $this->dlog('restore_documents: file created', [
630
                    'title' => $finalTitle,
631
                    'iid'   => $iid,
632
                    'mode'  => $copyMode ? 'copy' : 'import'
633
                ]);
634
            } catch (\Throwable $e) {
635
                $this->dlog('restore_documents: file create failed', ['title' => $finalTitle, 'error' => $e->getMessage()]);
636
            }
637
        }
638
639
        $this->dlog('restore_documents: end');
640
    }
641
642
    /**
643
     * Compact dump of resources: keys, per-bag counts and one sample (trimmed).
644
     */
645
    private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void
646
    {
647
        try {
648
            $resources = is_array($this->course->resources ?? null) ? $this->course->resources : [];
649
650
            $safe = function ($data): string {
651
                try {
652
                    return json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]';
653
                } catch (\Throwable $e) {
654
                    return '[json_exception: '.$e->getMessage().']';
655
                }
656
            };
657
            $short = function ($v, int $max = 200) {
658
                if (is_string($v)) {
659
                    $s = trim($v);
660
                    return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s;
661
                }
662
                if (is_numeric($v) || is_bool($v) || $v === null) return $v;
663
                return '['.gettype($v).']';
664
            };
665
            $sample = function ($item) use ($short, $maxObjFields) {
666
                $out = [
667
                    'source_id'      => null,
668
                    'destination_id' => null,
669
                    'type'           => null,
670
                    'has_obj'        => false,
671
                    'obj_fields'     => [],
672
                    'has_item_props' => false,
673
                    'extra'          => [],
674
                ];
675
                if (is_object($item) || is_array($item)) {
676
                    $arr = (array)$item;
677
                    $out['source_id']      = $arr['source_id']      ?? null;
678
                    $out['destination_id'] = $arr['destination_id'] ?? null;
679
                    $out['type']           = $arr['type']           ?? null;
680
                    $out['has_item_props'] = !empty($arr['item_properties']);
681
682
                    $obj = $arr['obj'] ?? null;
683
                    if (is_object($obj) || is_array($obj)) {
684
                        $out['has_obj'] = true;
685
                        $objArr = (array)$obj;
686
                        $fields = [];
687
                        $i = 0;
688
                        foreach ($objArr as $k => $v) {
689
                            if ($i++ >= $maxObjFields) { $fields['__notice'] = 'truncated'; break; }
690
                            $fields[$k] = $short($v);
691
                        }
692
                        $out['obj_fields'] = $fields;
693
                    }
694
                    foreach (['path','title','comment'] as $k) {
695
                        if (isset($arr[$k])) $out['extra'][$k] = $short($arr[$k]);
696
                    }
697
                } else {
698
                    $out['extra']['_type'] = gettype($item);
699
                }
700
                return $out;
701
            };
702
703
            $this->dlog('Resources overview', ['keys' => array_keys($resources)]);
704
705
            foreach ($resources as $bagName => $bag) {
706
                if (!is_array($bag)) {
707
                    $this->dlog("Bag not an array, skipping", ['bag' => $bagName, 'type' => gettype($bag)]);
708
                    continue;
709
                }
710
                $count = count($bag);
711
                $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]);
712
713
                if ($count > 0) {
714
                    $firstKey = array_key_first($bag);
715
                    $firstVal = $bag[$firstKey];
716
                    $s = $sample($firstVal);
717
                    $s['__first_key'] = $firstKey;
718
                    $s['__class']     = is_object($firstVal) ? get_class($firstVal) : gettype($firstVal);
719
                    $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]);
720
                }
721
722
                if ($focusBag !== null && $focusBag === $bagName) {
723
                    $preview = [];
724
                    $i = 0;
725
                    foreach ($bag as $k => $v) {
726
                        if ($i++ >= 10) { $preview[] = ['__notice' => 'truncated-after-10-items']; break; }
727
                        $preview[] = ['key' => $k, 'sample' => $sample($v)];
728
                    }
729
                    $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]);
730
                }
731
            }
732
        } catch (\Throwable $e) {
733
            $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]);
734
        }
735
    }
736
737
    public function restore_forum_category($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void
738
    {
739
        $bag = $this->course->resources['Forum_Category']
740
            ?? $this->course->resources['forum_category']
741
            ?? [];
742
743
        if (empty($bag)) {
744
            $this->dlog('restore_forum_category: empty bag');
745
            return;
746
        }
747
748
        $em      = Database::getManager();
749
        $catRepo = Container::getForumCategoryRepository();
750
        $course  = api_get_course_entity($this->destination_course_id);
751
        $session = api_get_session_entity((int)$session_id);
752
753
        foreach ($bag as $id => $res) {
754
            if (!empty($res->destination_id)) { continue; }
755
756
            $obj     = is_object($res->obj ?? null) ? $res->obj : (object)[];
757
            $title   = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$id");
758
            $comment = (string)($obj->cat_comment ?? $obj->description ?? '');
759
760
            $existing = $catRepo->findOneBy(['title' => $title, 'resourceNode.parent' => $course->getResourceNode()]);
761
            if ($existing) {
762
                $destIid = (int)$existing->getIid();
763
                if (!isset($this->course->resources['Forum_Category'])) {
764
                    $this->course->resources['Forum_Category'] = [];
765
                }
766
                $this->course->resources['Forum_Category'][$id]->destination_id = $destIid;
767
                $this->dlog('restore_forum_category: reuse existing', ['title' => $title, 'iid' => $destIid]);
768
                continue;
769
            }
770
771
            $cat = (new CForumCategory())
772
                ->setTitle($title)
773
                ->setCatComment($comment)
774
                ->setParent($course)
775
                ->addCourseLink($course, $session);
776
777
            $catRepo->create($cat);
778
            $em->flush();
779
780
            $this->course->resources['Forum_Category'][$id]->destination_id = (int)$cat->getIid();
781
            $this->dlog('restore_forum_category: created', ['title' => $title, 'iid' => (int)$cat->getIid()]);
782
        }
783
784
        $this->dlog('restore_forum_category: done', ['count' => count($bag)]);
785
    }
786
787
    public function restore_forums(int $sessionId = 0): void
788
    {
789
        $forumsBag = $this->course->resources['forum'] ?? [];
790
        if (empty($forumsBag)) {
791
            $this->dlog('restore_forums: empty forums bag');
792
            return;
793
        }
794
795
        $em        = Database::getManager();
796
        $catRepo   = Container::getForumCategoryRepository();
797
        $forumRepo = Container::getForumRepository();
798
799
        $course  = api_get_course_entity($this->destination_course_id);
800
        $session = api_get_session_entity($sessionId);
801
802
        // Build/ensure categories
803
        $catBag = $this->course->resources['Forum_Category'] ?? $this->course->resources['forum_category'] ?? [];
804
        $catMap = [];
805
806
        if (!empty($catBag)) {
807
            foreach ($catBag as $srcCatId => $res) {
808
                if (!empty($res->destination_id)) {
809
                    $catMap[(int)$srcCatId] = (int)$res->destination_id;
810
                    continue;
811
                }
812
813
                $obj     = is_object($res->obj ?? null) ? $res->obj : (object)[];
814
                $title   = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$srcCatId");
815
                $comment = (string)($obj->cat_comment ?? $obj->description ?? '');
816
817
                $cat = (new CForumCategory())
818
                    ->setTitle($title)
819
                    ->setCatComment($comment)
820
                    ->setParent($course)
821
                    ->addCourseLink($course, $session);
822
823
                $catRepo->create($cat);
824
                $em->flush();
825
826
                $destIid = (int)$cat->getIid();
827
                $catMap[(int)$srcCatId] = $destIid;
828
829
                if (!isset($this->course->resources['Forum_Category'])) {
830
                    $this->course->resources['Forum_Category'] = [];
831
                }
832
                $this->course->resources['Forum_Category'][$srcCatId]->destination_id = $destIid;
833
834
                $this->dlog('restore_forums: created category', ['src_id' => (int)$srcCatId, 'iid' => $destIid, 'title' => $title]);
835
            }
836
        }
837
838
        // Default category "General" if needed
839
        $defaultCategory = null;
840
        $ensureDefault = function() use (&$defaultCategory, $course, $session, $catRepo, $em): CForumCategory {
841
            if ($defaultCategory instanceof CForumCategory) {
842
                return $defaultCategory;
843
            }
844
            $defaultCategory = (new CForumCategory())
845
                ->setTitle('General')
846
                ->setCatComment('')
847
                ->setParent($course)
848
                ->addCourseLink($course, $session);
849
            $catRepo->create($defaultCategory);
850
            $em->flush();
851
            return $defaultCategory;
852
        };
853
854
        // Create forums and their topics
855
        foreach ($forumsBag as $srcForumId => $forumRes) {
856
            if (!is_object($forumRes) || !is_object($forumRes->obj)) { continue; }
857
            $p = (array)$forumRes->obj;
858
859
            $dstCategory = null;
860
            $srcCatId = (int)($p['forum_category'] ?? 0);
861
            if ($srcCatId > 0 && isset($catMap[$srcCatId])) {
862
                $dstCategory = $catRepo->find($catMap[$srcCatId]);
863
            }
864
            if (!$dstCategory && count($catMap) === 1) {
865
                $onlyDestIid = (int)reset($catMap);
866
                $dstCategory = $catRepo->find($onlyDestIid);
867
            }
868
            if (!$dstCategory) {
869
                $dstCategory = $ensureDefault();
870
            }
871
872
            $forum = (new CForum())
873
                ->setTitle($p['forum_title'] ?? ('Forum #'.$srcForumId))
874
                ->setForumComment((string)($p['forum_comment'] ?? ''))
875
                ->setForumCategory($dstCategory)
876
                ->setAllowAnonymous((int)($p['allow_anonymous'] ?? 0))
877
                ->setAllowEdit((int)($p['allow_edit'] ?? 0))
878
                ->setApprovalDirectPost((string)($p['approval_direct_post'] ?? '0'))
879
                ->setAllowAttachments((int)($p['allow_attachments'] ?? 1))
880
                ->setAllowNewThreads((int)($p['allow_new_threads'] ?? 1))
881
                ->setDefaultView($p['default_view'] ?? 'flat')
882
                ->setForumOfGroup((string)($p['forum_of_group'] ?? 0))
883
                ->setForumGroupPublicPrivate($p['forum_group_public_private'] ?? 'public')
884
                ->setModerated((bool)($p['moderated'] ?? false))
885
                ->setStartTime(!empty($p['start_time']) && $p['start_time'] !== '0000-00-00 00:00:00'
886
                    ? api_get_utc_datetime($p['start_time'], true, true) : null)
887
                ->setEndTime(!empty($p['end_time']) && $p['end_time'] !== '0000-00-00 00:00:00'
888
                    ? api_get_utc_datetime($p['end_time'], true, true) : null)
889
                ->setParent($dstCategory ?: $course)
890
                ->addCourseLink($course, $session);
891
892
            $forumRepo->create($forum);
893
            $em->flush();
894
895
            $this->course->resources['forum'][$srcForumId]->destination_id = (int)$forum->getIid();
896
            $this->dlog('restore_forums: created forum', [
897
                'src_forum_id' => (int)$srcForumId,
898
                'dst_forum_iid'=> (int)$forum->getIid(),
899
                'category_iid' => (int)$dstCategory->getIid(),
900
            ]);
901
902
            // Topics of this forum
903
            $topicsBag = $this->course->resources['thread'] ?? [];
904
            foreach ($topicsBag as $srcThreadId => $topicRes) {
905
                if (!is_object($topicRes) || !is_object($topicRes->obj)) { continue; }
906
                if ((int)$topicRes->obj->forum_id === (int)$srcForumId) {
907
                    $tid = $this->restore_topic((int)$srcThreadId, (int)$forum->getIid(), $sessionId);
908
                    $this->dlog('restore_forums: topic restored', [
909
                        'src_thread_id' => (int)$srcThreadId,
910
                        'dst_thread_iid'=> (int)($tid ?? 0),
911
                        'dst_forum_iid' => (int)$forum->getIid(),
912
                    ]);
913
                }
914
            }
915
        }
916
917
        $this->dlog('restore_forums: done', ['forums' => count($forumsBag)]);
918
    }
919
920
    public function restore_topic(int $srcThreadId, int $dstForumId, int $sessionId = 0): ?int
921
    {
922
        $topicsBag = $this->course->resources['thread'] ?? [];
923
        $topicRes  = $topicsBag[$srcThreadId] ?? null;
924
        if (!$topicRes || !is_object($topicRes->obj)) {
925
            $this->dlog('restore_topic: missing topic object', ['src_thread_id' => $srcThreadId]);
926
            return null;
927
        }
928
929
        $em         = Database::getManager();
930
        $forumRepo  = Container::getForumRepository();
931
        $threadRepo = Container::getForumThreadRepository();
932
        $postRepo   = Container::getForumPostRepository();
933
934
        $course  = api_get_course_entity($this->destination_course_id);
935
        $session = api_get_session_entity((int)$sessionId);
936
        $user    = api_get_user_entity($this->first_teacher_id);
937
938
        /** @var CForum|null $forum */
939
        $forum = $forumRepo->find($dstForumId);
940
        if (!$forum) {
941
            $this->dlog('restore_topic: destination forum not found', ['dst_forum_id' => $dstForumId]);
942
            return null;
943
        }
944
945
        $p = (array)$topicRes->obj;
946
947
        $thread = (new CForumThread())
948
            ->setTitle((string)($p['thread_title'] ?? "Thread #$srcThreadId"))
949
            ->setForum($forum)
950
            ->setUser($user)
951
            ->setThreadDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')))
952
            ->setThreadSticky((bool)($p['thread_sticky'] ?? false))
953
            ->setThreadTitleQualify((string)($p['thread_title_qualify'] ?? ''))
954
            ->setThreadQualifyMax((float)($p['thread_qualify_max'] ?? 0))
955
            ->setThreadWeight((float)($p['thread_weight'] ?? 0))
956
            ->setThreadPeerQualify((bool)($p['thread_peer_qualify'] ?? false))
957
            ->setParent($forum)
958
            ->addCourseLink($course, $session);
959
960
        $threadRepo->create($thread);
961
        $em->flush();
962
963
        $this->course->resources['thread'][$srcThreadId]->destination_id = (int)$thread->getIid();
964
        $this->dlog('restore_topic: created', [
965
            'src_thread_id' => $srcThreadId,
966
            'dst_thread_iid'=> (int)$thread->getIid(),
967
            'dst_forum_iid' => (int)$forum->getIid(),
968
        ]);
969
970
        // Posts
971
        $postsBag = $this->course->resources[ 'post'] ?? [];
972
        foreach ($postsBag as $srcPostId => $postRes) {
973
            if (!is_object($postRes) || !is_object($postRes->obj)) { continue; }
974
            if ((int)$postRes->obj->thread_id === (int)$srcThreadId) {
975
                $pid = $this->restore_post((int)$srcPostId, (int)$thread->getIid(), (int)$forum->getIid(), $sessionId);
976
                $this->dlog('restore_topic: post restored', ['src_post_id' => (int)$srcPostId, 'dst_post_iid' => (int)($pid ?? 0)]);
977
            }
978
        }
979
980
        $last = $postRepo->findOneBy(['thread' => $thread], ['postDate' => 'DESC']);
981
        if ($last) {
982
            $thread->setThreadLastPost($last);
983
            $em->persist($thread);
984
            $em->flush();
985
        }
986
987
        return (int)$thread->getIid();
988
    }
989
990
    public function restore_post(int $srcPostId, int $dstThreadId, int $dstForumId, int $sessionId = 0): ?int
991
    {
992
        $postsBag = $this->course->resources['post'] ?? [];
993
        $postRes  = $postsBag[$srcPostId] ?? null;
994
        if (!$postRes || !is_object($postRes->obj)) {
995
            $this->dlog('restore_post: missing post object', ['src_post_id' => $srcPostId]);
996
            return null;
997
        }
998
999
        $em         = Database::getManager();
1000
        $forumRepo  = Container::getForumRepository();
1001
        $threadRepo = Container::getForumThreadRepository();
1002
        $postRepo   = Container::getForumPostRepository();
1003
1004
        $course  = api_get_course_entity($this->destination_course_id);
1005
        $session = api_get_session_entity((int)$sessionId);
1006
        $user    = api_get_user_entity($this->first_teacher_id);
1007
1008
        $thread = $threadRepo->find($dstThreadId);
1009
        $forum  = $forumRepo->find($dstForumId);
1010
        if (!$thread || !$forum) {
1011
            $this->dlog('restore_post: destination thread/forum not found', [
1012
                'dst_thread_id' => $dstThreadId,
1013
                'dst_forum_id'  => $dstForumId,
1014
            ]);
1015
            return null;
1016
        }
1017
1018
        $p = (array)$postRes->obj;
1019
1020
        $post = (new CForumPost())
1021
            ->setTitle((string)($p['post_title'] ?? "Post #$srcPostId"))
1022
            ->setPostText((string)($p['post_text'] ?? ''))
1023
            ->setThread($thread)
1024
            ->setForum($forum)
1025
            ->setUser($user)
1026
            ->setPostDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')))
1027
            ->setPostNotification((bool)($p['post_notification'] ?? false))
1028
            ->setVisible(true)
1029
            ->setStatus(CForumPost::STATUS_VALIDATED)
1030
            ->setParent($thread)
1031
            ->addCourseLink($course, $session);
1032
1033
        if (!empty($p['post_parent_id'])) {
1034
            $parentDestId = (int)($postsBag[$p['post_parent_id']]->destination_id ?? 0);
1035
            if ($parentDestId > 0) {
1036
                $parent = $postRepo->find($parentDestId);
1037
                if ($parent) {
1038
                    $post->setPostParent($parent);
1039
                }
1040
            }
1041
        }
1042
1043
        $postRepo->create($post);
1044
        $em->flush();
1045
1046
        $this->course->resources['post'][$srcPostId]->destination_id = (int)$post->getIid();
1047
        $this->dlog('restore_post: created', [
1048
            'src_post_id'   => (int)$srcPostId,
1049
            'dst_post_iid'  => (int)$post->getIid(),
1050
            'dst_thread_id' => (int)$thread->getIid(),
1051
            'dst_forum_id'  => (int)$forum->getIid(),
1052
        ]);
1053
1054
        return (int)$post->getIid();
1055
    }
1056
1057
    public function restore_link_category($id, $sessionId = 0)
1058
    {
1059
        $sessionId = (int) $sessionId;
1060
1061
        // "No category" short-circuit (legacy used 0 as 'uncategorized').
1062
        if (0 === (int) $id) {
1063
            $this->dlog('restore_link_category: source category is 0 (no category), returning 0');
1064
1065
            return 0;
1066
        }
1067
1068
        $resources = $this->course->resources ?? [];
1069
        $srcCat = $resources[RESOURCE_LINKCATEGORY][$id] ?? null;
1070
1071
        if (!is_object($srcCat)) {
1072
            error_log('COURSE_DEBUG: restore_link_category: source category object not found for id ' . $id);
1073
1074
            return 0;
1075
        }
1076
1077
        // Already restored?
1078
        if (!empty($srcCat->destination_id)) {
1079
            return (int) $srcCat->destination_id;
1080
        }
1081
1082
        $em = Database::getManager();
1083
        $catRepo = Container::getLinkCategoryRepository();
1084
        $course = api_get_course_entity($this->destination_course_id);
1085
        $session = api_get_session_entity($sessionId);
1086
1087
        // Normalize incoming values
1088
        $title = (string) ($srcCat->title ?? $srcCat->category_title ?? 'Links');
1089
        $description = (string) ($srcCat->description ?? '');
1090
1091
        // Try to find existing category by *title* under this course (we'll filter by course parent in PHP)
1092
        $candidates = $catRepo->findBy(['title' => $title]);
1093
1094
        $existing = null;
1095
        if (!empty($candidates)) {
1096
            $courseNode = $course->getResourceNode();
1097
            foreach ($candidates as $cand) {
1098
                $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1099
                $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1100
                if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) {
1101
                    $existing = $cand;
1102
                    break;
1103
                }
1104
            }
1105
        }
1106
1107
        // Collision handling
1108
        if ($existing) {
1109
            switch ($this->file_option) {
1110
                case FILE_SKIP:
1111
                    $destIid = (int) $existing->getIid();
1112
                    $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid;
1113
                    $this->dlog('restore_link_category: reuse (SKIP)', [
1114
                        'src_cat_id' => (int) $id,
1115
                        'dst_cat_id' => $destIid,
1116
                        'title' => $title,
1117
                    ]);
1118
1119
                    return $destIid;
1120
1121
                case FILE_OVERWRITE:
1122
                    // Update description (keep title)
1123
                    $existing->setDescription($description);
1124
                    // Ensure course/session link
1125
                    if (method_exists($existing, 'setParent')) {
1126
                        $existing->setParent($course);
1127
                    }
1128
                    if (method_exists($existing, 'addCourseLink')) {
1129
                        $existing->addCourseLink($course, $session);
1130
                    }
1131
1132
                    $em->persist($existing);
1133
                    $em->flush();
1134
1135
                    $destIid = (int) $existing->getIid();
1136
                    $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid;
1137
                    $this->dlog('restore_link_category: overwrite', [
1138
                        'src_cat_id' => (int) $id,
1139
                        'dst_cat_id' => $destIid,
1140
                        'title' => $title,
1141
                    ]);
1142
1143
                    return $destIid;
1144
1145
                case FILE_RENAME:
1146
                default:
1147
                    // Create a new unique title inside the same course parent
1148
                    $base = $title;
1149
                    $i = 1;
1150
                    do {
1151
                        $title = $base . ' (' . $i . ')';
1152
                        $candidates = $catRepo->findBy(['title' => $title]);
1153
                        $exists = false;
1154
1155
                        if (!empty($candidates)) {
1156
                            $courseNode = $course->getResourceNode();
1157
                            foreach ($candidates as $cand) {
1158
                                $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1159
                                $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1160
                                if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) {
1161
                                    $exists = true;
1162
                                    break;
1163
                                }
1164
                            }
1165
                        }
1166
1167
                        $i++;
1168
                    } while ($exists);
1169
                    break;
1170
            }
1171
        }
1172
1173
        // Create new category
1174
        $cat = (new CLinkCategory())
1175
            ->setTitle($title)
1176
            ->setDescription($description);
1177
1178
        if (method_exists($cat, 'setParent')) {
1179
            $cat->setParent($course); // parent ResourceNode: Course
1180
        }
1181
        if (method_exists($cat, 'addCourseLink')) {
1182
            $cat->addCourseLink($course, $session); // visibility link (course, session)
1183
        }
1184
1185
        $em->persist($cat);
1186
        $em->flush();
1187
1188
        $destIid = (int) $cat->getIid();
1189
        $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid;
1190
1191
        $this->dlog('restore_link_category: created', [
1192
            'src_cat_id' => (int) $id,
1193
            'dst_cat_id' => $destIid,
1194
            'title' => (string) $title,
1195
        ]);
1196
1197
        return $destIid;
1198
    }
1199
1200
    public function restore_links($session_id = 0)
1201
    {
1202
        if (!$this->course->has_resources(RESOURCE_LINK)) {
1203
            return;
1204
        }
1205
1206
        $resources = $this->course->resources;
1207
        $count = is_array($resources[RESOURCE_LINK] ?? null) ? count($resources[RESOURCE_LINK]) : 0;
1208
1209
        $this->dlog('restore_links: begin', ['count' => $count]);
1210
1211
        $em = Database::getManager();
1212
        $linkRepo = Container::getLinkRepository();
1213
        $catRepo = Container::getLinkCategoryRepository();
1214
        $course = api_get_course_entity($this->destination_course_id);
1215
        $session = api_get_session_entity((int) $session_id);
1216
1217
        // Safe duplicate finder (no dot-path in criteria; filter parent in PHP)
1218
        $findDuplicate = function (string $t, string $u, ?CLinkCategory $cat) use ($linkRepo, $course) {
1219
            $criteria = ['title' => $t, 'url' => $u];
1220
            $criteria['category'] = $cat instanceof CLinkCategory ? $cat : null;
1221
1222
            $candidates = $linkRepo->findBy($criteria);
1223
            if (empty($candidates)) {
1224
                return null;
1225
            }
1226
1227
            $courseNode = $course->getResourceNode();
1228
            foreach ($candidates as $cand) {
1229
                $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null;
1230
                $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null;
1231
                if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) {
1232
                    return $cand;
1233
                }
1234
            }
1235
1236
            return null;
1237
        };
1238
1239
        foreach ($resources[RESOURCE_LINK] as $oldLinkId => $link) {
1240
            // Normalize (accept values from object or "extra")
1241
            $rawUrl = (string) ($link->url ?? ($link->extra['url'] ?? ''));
1242
            $rawTitle = (string) ($link->title ?? ($link->extra['title'] ?? ''));
1243
            $rawDesc = (string) ($link->description ?? ($link->extra['description'] ?? ''));
1244
            $target = isset($link->target) ? (string) $link->target : null;
1245
            $catSrcId = (int) ($link->category_id ?? 0);
1246
            $onHome = (bool) ($link->on_homepage ?? false);
1247
1248
            $url = trim($rawUrl);
1249
            $title = trim($rawTitle) !== '' ? trim($rawTitle) : $url;
1250
1251
            if ($url === '') {
1252
                $this->dlog('restore_links: skipped (empty URL)', [
1253
                    'src_link_id' => (int) $oldLinkId,
1254
                    'has_obj' => !empty($link->has_obj),
1255
                    'extra_keys' => isset($link->extra) ? implode(',', array_keys((array) $link->extra)) : '',
1256
                ]);
1257
                continue;
1258
            }
1259
1260
            // Resolve / create destination category if source had one; otherwise null
1261
            $category = null;
1262
            if ($catSrcId > 0) {
1263
                $dstCatIid = (int) $this->restore_link_category($catSrcId, (int) $session_id);
1264
                if ($dstCatIid > 0) {
1265
                    $category = $catRepo->find($dstCatIid);
1266
                } else {
1267
                    $this->dlog('restore_links: category not available, using null', [
1268
                        'src_link_id' => (int) $oldLinkId,
1269
                        'src_cat_id' => (int) $catSrcId,
1270
                    ]);
1271
                }
1272
            }
1273
1274
            // Duplicate handling (title + url + category in same course)
1275
            $existing = $findDuplicate($title, $url, $category);
1276
1277
            if ($existing) {
1278
                if ($this->file_option === FILE_SKIP) {
1279
                    $destIid = (int) $existing->getIid();
1280
                    $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass();
1281
                    $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1282
1283
                    $this->dlog('restore_links: reuse (SKIP)', [
1284
                        'src_link_id' => (int) $oldLinkId,
1285
                        'dst_link_id' => $destIid,
1286
                        'title' => $title,
1287
                        'url' => $url,
1288
                    ]);
1289
1290
                    continue;
1291
                }
1292
1293
                if ($this->file_option === FILE_OVERWRITE) {
1294
                    // Update main fields (keep position/shortcut logic outside)
1295
                    $existing
1296
                        ->setUrl($url)
1297
                        ->setTitle($title)
1298
                        ->setDescription($rawDesc) // rewrite to assets after flush
1299
                        ->setTarget((string) ($target ?? ''));
1300
1301
                    if (method_exists($existing, 'setParent')) {
1302
                        $existing->setParent($course);
1303
                    }
1304
                    if (method_exists($existing, 'addCourseLink')) {
1305
                        $existing->addCourseLink($course, $session);
1306
                    }
1307
                    $existing->setCategory($category); // can be null
1308
1309
                    $em->persist($existing);
1310
                    $em->flush();
1311
1312
                    // Now rewrite legacy "document/..." URLs inside description to Assets
1313
                    try {
1314
                        $backupRoot = $this->course->backup_path ?? '';
1315
                        $extraRoots = array_filter([
1316
                            $this->course->destination_path ?? '',
1317
                            $this->course->origin_path ?? '',
0 ignored issues
show
Bug introduced by
The property origin_path does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
1318
                        ]);
1319
                        $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
1320
                            $rawDesc,
1321
                            $existing,
1322
                            $backupRoot,
1323
                            $extraRoots
1324
                        );
1325
1326
                        if ($rewritten !== $rawDesc) {
1327
                            $existing->setDescription($rewritten);
1328
                            $em->persist($existing);
1329
                            $em->flush();
1330
                        }
1331
                    } catch (\Throwable $e) {
1332
                        error_log('COURSE_DEBUG: restore_links: asset rewrite failed (overwrite): ' . $e->getMessage());
1333
                    }
1334
1335
                    $destIid = (int) $existing->getIid();
1336
                    $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass();
1337
                    $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1338
1339
                    $this->dlog('restore_links: overwrite', [
1340
                        'src_link_id' => (int) $oldLinkId,
1341
                        'dst_link_id' => $destIid,
1342
                        'title' => $title,
1343
                        'url' => $url,
1344
                    ]);
1345
1346
                    continue;
1347
                }
1348
1349
                // FILE_RENAME (default): make title unique among same course/category
1350
                $base = $title;
1351
                $i = 1;
1352
                do {
1353
                    $title = $base . ' (' . $i . ')';
1354
                    $i++;
1355
                } while ($findDuplicate($title, $url, $category));
1356
            }
1357
1358
            // Create new link entity
1359
            $entity = (new CLink())
1360
                ->setUrl($url)
1361
                ->setTitle($title)
1362
                ->setDescription($rawDesc) // rewrite to assets after first flush
1363
                ->setTarget((string) ($target ?? ''));
1364
1365
            if (method_exists($entity, 'setParent')) {
1366
                $entity->setParent($course); // parent ResourceNode: Course
1367
            }
1368
            if (method_exists($entity, 'addCourseLink')) {
1369
                $entity->addCourseLink($course, $session); // visibility (course, session)
1370
            }
1371
1372
            if ($category instanceof CLinkCategory) {
1373
                $entity->setCategory($category);
1374
            }
1375
1376
            // Persist to create the ResourceNode; we need it for Asset attachment
1377
            $em->persist($entity);
1378
            $em->flush();
1379
1380
            // Rewrite legacy "document/..." URLs inside description to Assets, then save if changed
1381
            try {
1382
                $backupRoot = $this->course->backup_path ?? '';
1383
                $extraRoots = array_filter([
1384
                    $this->course->destination_path ?? '',
1385
                    $this->course->origin_path ?? '',
1386
                ]);
1387
                $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
1388
                    $rawDesc,
1389
                    $entity,
1390
                    (string) $backupRoot,
1391
                    $extraRoots
1392
                );
1393
1394
                if ($rewritten !== (string) $rawDesc) {
1395
                    $entity->setDescription($rewritten);
1396
                    $em->persist($entity);
1397
                    $em->flush();
1398
                }
1399
            } catch (\Throwable $e) {
1400
                error_log('COURSE_DEBUG: restore_links: asset rewrite failed (create): ' . $e->getMessage());
1401
            }
1402
1403
            // Map destination id back into resources
1404
            $destIid = (int) $entity->getIid();
1405
1406
            if (!isset($this->course->resources[RESOURCE_LINK][$oldLinkId])) {
1407
                $this->course->resources[RESOURCE_LINK][$oldLinkId] = new \stdClass();
1408
            }
1409
            $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid;
1410
1411
            $this->dlog('restore_links: created', [
1412
                'src_link_id' => (int) $oldLinkId,
1413
                'dst_link_id' => $destIid,
1414
                'title' => $title,
1415
                'url' => $url,
1416
                'category' => $category ? $category->getTitle() : null,
1417
            ]);
1418
1419
            // Optional: emulate "show on homepage" by ensuring ResourceLink exists (UI/Controller handles real shortcut)
1420
            if (!empty($onHome)) {
1421
                try {
1422
                    // Ensure resource link is persisted (it already is via addCourseLink)
1423
                    // Any actual shortcut creation should be delegated to the appropriate service/controller.
1424
                    $em->persist($entity);
1425
                    $em->flush();
1426
                } catch (\Throwable $e) {
1427
                    error_log('COURSE_DEBUG: restore_links: homepage flag handling failed: ' . $e->getMessage());
1428
                }
1429
            }
1430
        }
1431
1432
        $this->dlog('restore_links: end');
1433
    }
1434
1435
    public function restore_tool_intro($sessionId = 0)
1436
    {
1437
        $resources = $this->course->resources ?? [];
1438
        $bagKey = null;
1439
        if ($this->course->has_resources(RESOURCE_TOOL_INTRO)) {
1440
            $bagKey = RESOURCE_TOOL_INTRO;
1441
        } elseif (!empty($resources['Tool introduction'])) {
1442
            $bagKey = 'Tool introduction';
1443
        }
1444
        if ($bagKey === null || empty($resources[$bagKey]) || !is_array($resources[$bagKey])) {
1445
            return;
1446
        }
1447
1448
        $sessionId = (int) $sessionId;
1449
        $this->dlog('restore_tool_intro: begin', ['count' => count($resources[$bagKey])]);
1450
1451
        $em      = \Database::getManager();
1452
        $course  = api_get_course_entity($this->destination_course_id);
1453
        $session = $sessionId ? api_get_session_entity($sessionId) : null;
1454
1455
        $toolRepo  = $em->getRepository(Tool::class);
1456
        $cToolRepo = $em->getRepository(CTool::class);
1457
        $introRepo = $em->getRepository(CToolIntro::class);
1458
1459
        $rewriteContent = function (string $html) {
1460
            if ($html === '') return '';
1461
            try {
1462
                if (class_exists(ChamiloHelper::class)
1463
                    && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')
1464
                ) {
1465
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
1466
                        $html,
1467
                        api_get_course_entity($this->destination_course_id),
1468
                        (string)($this->course->backup_path ?? ''),
1469
                        array_filter([
1470
                            (string)($this->course->destination_path ?? ''),
1471
                            (string)($this->course->info['path'] ?? ''),
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
1472
                        ])
1473
                    );
1474
                }
1475
            } catch (\Throwable $e) {
1476
                error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed (tool_intro): '.$e->getMessage());
1477
            }
1478
1479
            $out = \DocumentManager::replaceUrlWithNewCourseCode(
1480
                $html,
1481
                $this->course->code,
1482
                $this->course->destination_path,
1483
                $this->course->backup_path,
1484
                $this->course->info['path']
1485
            );
1486
            return $out === false ? '' : $out;
1487
        };
1488
1489
        foreach ($resources[$bagKey] as $rawId => $tIntro) {
1490
            // prefer source->id only if non-empty AND not "0"; otherwise use the bag key ($rawId)
1491
            $toolKey = trim((string)($tIntro->id ?? ''));
1492
            if ($toolKey === '' || $toolKey === '0') {
1493
                $toolKey = (string)$rawId;
1494
            }
1495
1496
            // normalize a couple of common aliases defensively
1497
            $alias = strtolower($toolKey);
1498
            if ($alias === 'homepage' || $alias === 'course_home') {
1499
                $toolKey = 'course_homepage';
1500
            }
1501
1502
            // log exactly what we got to avoid future confusion
1503
            $this->dlog('restore_tool_intro: resolving tool key', [
1504
                'raw_id'  => (string)$rawId,
1505
                'obj_id'  => isset($tIntro->id) ? (string)$tIntro->id : null,
1506
                'toolKey' => $toolKey,
1507
            ]);
1508
1509
            $mapped = $tIntro->destination_id ?? 0;
1510
            if ($mapped > 0) {
1511
                $this->dlog('restore_tool_intro: already mapped, skipping', ['src_id' => $toolKey, 'dst_id' => $mapped]);
1512
                continue;
1513
            }
1514
1515
            $introHtml = $rewriteContent($tIntro->intro_text ?? '');
1516
1517
            // find core Tool by title (e.g., 'course_homepage')
1518
            $toolEntity = $toolRepo->findOneBy(['title' => $toolKey]);
1519
            if (!$toolEntity) {
1520
                $this->dlog('restore_tool_intro: missing Tool entity, skipping', ['tool' => $toolKey]);
1521
                continue;
1522
            }
1523
1524
            // find or create the CTool row for this course+session+title
1525
            $cTool = $cToolRepo->findOneBy([
1526
                'course'  => $course,
1527
                'session' => $session,
1528
                'title'   => $toolKey,
1529
            ]);
1530
1531
            if (!$cTool) {
1532
                $cTool = (new CTool())
1533
                    ->setTool($toolEntity)
1534
                    ->setTitle($toolKey)
1535
                    ->setCourse($course)
1536
                    ->setSession($session)
1537
                    ->setPosition(1)
1538
                    ->setVisibility(true)
1539
                    ->setParent($course)
1540
                    ->setCreator($course->getCreator() ?? null)
1541
                    ->addCourseLink($course);
1542
1543
                $em->persist($cTool);
1544
                $em->flush();
1545
1546
                $this->dlog('restore_tool_intro: CTool created', [
1547
                    'tool'     => $toolKey,
1548
                    'ctool_id' => (int)$cTool->getIid(),
1549
                ]);
1550
            }
1551
1552
            $intro = $introRepo->findOneBy(['courseTool' => $cTool]);
1553
1554
            if ($intro) {
1555
                if ($this->file_option === FILE_SKIP) {
1556
                    $this->dlog('restore_tool_intro: reuse existing (SKIP)', [
1557
                        'tool'     => $toolKey,
1558
                        'intro_id' => (int)$intro->getIid(),
1559
                    ]);
1560
                } else {
1561
                    $intro->setIntroText($introHtml);
1562
                    $em->persist($intro);
1563
                    $em->flush();
1564
1565
                    $this->dlog('restore_tool_intro: intro overwritten', [
1566
                        'tool'     => $toolKey,
1567
                        'intro_id' => (int)$intro->getIid(),
1568
                    ]);
1569
                }
1570
            } else {
1571
                $intro = (new CToolIntro())
1572
                    ->setCourseTool($cTool)
1573
                    ->setIntroText($introHtml)
1574
                    ->setParent($course);
1575
1576
                $em->persist($intro);
1577
                $em->flush();
1578
1579
                $this->dlog('restore_tool_intro: intro created', [
1580
                    'tool'     => $toolKey,
1581
                    'intro_id' => (int)$intro->getIid(),
1582
                ]);
1583
            }
1584
1585
            // map destination back into the legacy resource bag
1586
            if (!isset($this->course->resources[$bagKey][$rawId])) {
1587
                $this->course->resources[$bagKey][$rawId] = new \stdClass();
1588
            }
1589
            $this->course->resources[$bagKey][$rawId]->destination_id = (int)$intro->getIid();
1590
        }
1591
1592
        $this->dlog('restore_tool_intro: end');
1593
    }
1594
1595
1596
    public function restore_events(int $sessionId = 0): void
1597
    {
1598
        if (!$this->course->has_resources(RESOURCE_EVENT)) {
1599
            return;
1600
        }
1601
1602
        $resources  = $this->course->resources ?? [];
1603
        $bag        = $resources[RESOURCE_EVENT] ?? [];
1604
        $count      = is_array($bag) ? count($bag) : 0;
1605
1606
        $this->dlog('restore_events: begin', ['count' => $count]);
1607
1608
        /** @var EntityManagerInterface $em */
1609
        $em          = \Database::getManager();
1610
        $course      = api_get_course_entity($this->destination_course_id);
1611
        $session     = api_get_session_entity($sessionId);
1612
        $group       = api_get_group_entity();
1613
        $eventRepo   = Container::getCalendarEventRepository();
1614
        $attachRepo  = Container::getCalendarEventAttachmentRepository();
1615
1616
        // Content rewrite helper (prefer new helper if available)
1617
        $rewriteContent = function (?string $html): string {
1618
            $html = $html ?? '';
1619
            if ($html === '') {
1620
                return '';
1621
            }
1622
            try {
1623
                if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) {
1624
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
1625
                        $html,
1626
                        api_get_course_entity($this->destination_course_id),
1627
                        $this->course->backup_path ?? '',
1628
                        array_filter([
1629
                            $this->course->destination_path ?? '',
1630
                            (string) ($this->course->info['path'] ?? ''),
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
1631
                        ])
1632
                    );
1633
                }
1634
            } catch (\Throwable $e) {
1635
                error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage());
1636
            }
1637
1638
            $out = \DocumentManager::replaceUrlWithNewCourseCode(
1639
                $html,
1640
                $this->course->code,
1641
                $this->course->destination_path,
1642
                $this->course->backup_path,
1643
                $this->course->info['path']
1644
            );
1645
1646
            return $out === false ? '' : (string) $out;
1647
        };
1648
1649
        // Dedupe by title inside same course/session (honor sameFileNameOption)
1650
        $findExistingByTitle = function (string $title) use ($eventRepo, $course, $session) {
1651
            $qb = $eventRepo->getResourcesByCourse($course, $session, null, null, true, true);
1652
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
1653
            return $qb->getQuery()->getOneOrNullResult();
1654
        };
1655
1656
        // Attachment source in backup zip (calendar)
1657
        $originPath = rtrim((string)($this->course->backup_path ?? ''), '/').'/upload/calendar/';
1658
1659
        foreach ($bag as $oldId => $raw) {
1660
            // Skip if already mapped to a positive destination id
1661
            $mapped = (int) ($raw->destination_id ?? 0);
1662
            if ($mapped > 0) {
1663
                $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int)$oldId, 'dst_id' => $mapped]);
1664
                continue;
1665
            }
1666
1667
            // Normalize input
1668
            $title = trim((string)($raw->title ?? ''));
1669
            if ($title === '') {
1670
                $title = 'Event';
1671
            }
1672
1673
            $content = $rewriteContent((string)($raw->content ?? ''));
1674
1675
            // Dates: accept various formats; allow empty endDate
1676
            $allDay   = (bool)($raw->all_day ?? false);
1677
            $start    = null;
1678
            $end      = null;
1679
            try {
1680
                $s = (string)($raw->start_date ?? '');
1681
                if ($s !== '') { $start = new \DateTime($s); }
1682
            } catch (\Throwable $e) { $start = null; }
1683
            try {
1684
                $e = (string)($raw->end_date ?? '');
1685
                if ($e !== '') { $end = new \DateTime($e); }
1686
            } catch (\Throwable $e) { $end = null; }
1687
1688
            // Dedupe policy
1689
            $existing = $findExistingByTitle($title);
1690
            if ($existing) {
1691
                switch ($this->file_option) {
1692
                    case FILE_SKIP:
1693
                        $destId = (int)$existing->getIid();
1694
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass();
1695
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
1696
                        $this->dlog('restore_events: reuse (SKIP)', [
1697
                            'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle()
1698
                        ]);
1699
                        // Try to add missing attachments (no duplicates by filename)
1700
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
1701
                        break;
1702
1703
                    case FILE_OVERWRITE:
1704
                        $existing
1705
                            ->setTitle($title)
1706
                            ->setContent($content)
1707
                            ->setAllDay($allDay)
1708
                            ->setParent($course)
1709
                            ->addCourseLink($course, $session, $group);
1710
1711
                        $existing->setStartDate($start);
1712
                        $existing->setEndDate($end);
1713
1714
                        $em->persist($existing);
1715
                        $em->flush();
1716
1717
                        $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass();
1718
                        $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int)$existing->getIid();
1719
1720
                        $this->dlog('restore_events: overwrite', [
1721
                            'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title
1722
                        ]);
1723
1724
                        $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em);
1725
                        break;
1726
1727
                    case FILE_RENAME:
1728
                    default:
1729
                        $base = $title;
1730
                        $i = 1;
1731
                        $candidate = $base;
1732
                        while ($findExistingByTitle($candidate)) {
1733
                            $i++;
1734
                            $candidate = $base.' ('.$i.')';
1735
                        }
1736
                        $title = $candidate;
1737
                        break;
1738
                }
1739
            }
1740
1741
            // Create new entity in course context
1742
            $entity = (new CCalendarEvent())
1743
                ->setTitle($title)
1744
                ->setContent($content)
1745
                ->setAllDay($allDay)
1746
                ->setParent($course)
1747
                ->addCourseLink($course, $session, $group);
1748
1749
            $entity->setStartDate($start);
1750
            $entity->setEndDate($end);
1751
1752
            $em->persist($entity);
1753
            $em->flush();
1754
1755
            // Map new id
1756
            $destId = (int)$entity->getIid();
1757
            $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass();
1758
            $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId;
1759
1760
            $this->dlog('restore_events: created', ['src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title]);
1761
1762
            // Attachments (backup modern / legacy)
1763
            $this->restoreEventAttachments($raw, $entity, $originPath, $attachRepo, $em);
1764
1765
            // (Optional) Repeat rules / reminders:
1766
            // If your backup exports recurrence/reminders, parse here and populate CCalendarEventRepeat / AgendaReminder.
1767
            // $this->restoreEventRecurrenceAndReminders($raw, $entity, $em);
1768
        }
1769
1770
        $this->dlog('restore_events: end');
1771
    }
1772
1773
    private function restoreEventAttachments(
1774
        object $raw,
1775
        CCalendarEvent $entity,
1776
        string $originPath,
1777
        $attachRepo,
1778
        EntityManagerInterface $em
1779
    ): void {
1780
        // Helper to actually persist + move file
1781
        $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em) {
1782
            if (!is_file($src) || !is_readable($src)) {
1783
                $this->dlog('restore_events: attachment source not readable', ['src' => $src]);
1784
                return;
1785
            }
1786
1787
            // Avoid duplicate filenames on same event
1788
            foreach ($entity->getAttachments() as $att) {
1789
                if ($att->getFilename() === $filename) {
1790
                    $this->dlog('restore_events: attachment already exists, skipping', ['filename' => $filename]);
1791
                    return;
1792
                }
1793
            }
1794
1795
            $attachment = (new CCalendarEventAttachment())
1796
                ->setFilename($filename)
1797
                ->setComment($comment ?? '')
1798
                ->setEvent($entity)
1799
                ->setParent($entity)
1800
                ->addCourseLink(
1801
                    api_get_course_entity($this->destination_course_id),
1802
                    api_get_session_entity(0),
1803
                    api_get_group_entity()
1804
                );
1805
1806
            $em->persist($attachment);
1807
            $em->flush();
1808
1809
            if (method_exists($attachRepo, 'addFileFromLocalPath')) {
1810
                $attachRepo->addFileFromLocalPath($attachment, $src);
1811
            } else {
1812
                $dstDir = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/calendar/';
0 ignored issues
show
Bug introduced by
The constant Chamilo\CourseBundle\Com...rseCopy\SYS_COURSE_PATH was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
1813
                @mkdir($dstDir, 0775, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

1813
                /** @scrutinizer ignore-unhandled */ @mkdir($dstDir, 0775, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1814
                $newName = uniqid('calendar_', true);
1815
                @copy($src, $dstDir.$newName);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for copy(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

1815
                /** @scrutinizer ignore-unhandled */ @copy($src, $dstDir.$newName);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1816
            }
1817
1818
            $this->dlog('restore_events: attachment created', [
1819
                'event_id' => (int)$entity->getIid(),
1820
                'filename' => $filename,
1821
            ]);
1822
        };
1823
1824
        // Case 1: modern backup fields on object
1825
        if (!empty($raw->attachment_path)) {
1826
            $src = rtrim($originPath, '/').'/'.$raw->attachment_path;
1827
            $filename = (string)($raw->attachment_filename ?? basename($src));
1828
            $comment  = (string)($raw->attachment_comment ?? '');
1829
            $persistAttachmentFromFile($src, $filename, $comment);
1830
            return;
1831
        }
1832
1833
        // Case 2: legacy lookup from old course tables when ->orig present
1834
        if (!empty($this->course->orig)) {
1835
            $table = \Database::get_course_table(TABLE_AGENDA_ATTACHMENT);
1836
            $sql = 'SELECT path, comment, filename
1837
                FROM '.$table.'
1838
                WHERE c_id = '.$this->destination_course_id.'
1839
                  AND agenda_id = '.(int)($raw->source_id ?? 0);
1840
            $res = \Database::query($sql);
1841
            while ($row = \Database::fetch_object($res)) {
1842
                $src = rtrim($originPath, '/').'/'.$row->path;
1843
                $persistAttachmentFromFile($src, (string)$row->filename, (string)$row->comment);
1844
            }
1845
        }
1846
    }
1847
1848
    public function restore_course_descriptions($session_id = 0)
1849
    {
1850
        if (!$this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) {
1851
            return;
1852
        }
1853
1854
        $resources = $this->course->resources;
1855
        $count = is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null)
1856
            ? count($resources[RESOURCE_COURSEDESCRIPTION])
1857
            : 0;
1858
1859
        $this->dlog('restore_course_descriptions: begin', ['count' => $count]);
1860
1861
        $em      = \Database::getManager();
1862
        $repo    = Container::getCourseDescriptionRepository();
1863
        $course  = api_get_course_entity($this->destination_course_id);
1864
        $session = api_get_session_entity((int) $session_id);
1865
1866
        $rewriteContent = function (string $html) use ($course) {
1867
            if ($html === '') {
1868
                return '';
1869
            }
1870
            if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) {
1871
                try {
1872
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
1873
                        $html,
1874
                        $course,
1875
                        $this->course->backup_path ?? '',
1876
                        array_filter([
1877
                            $this->course->destination_path ?? '',
1878
                            (string)($this->course->info['path'] ?? ''),
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
1879
                        ])
1880
                    );
1881
                } catch (\Throwable $e) {
1882
                    error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage());
1883
                }
1884
            }
1885
            $out = \DocumentManager::replaceUrlWithNewCourseCode(
1886
                $html,
1887
                $this->course->code,
1888
                $this->course->destination_path,
1889
                $this->course->backup_path,
1890
                $this->course->info['path']
1891
            );
1892
1893
            return $out === false ? '' : $out;
1894
        };
1895
1896
        $findByTypeInCourse = function (int $type) use ($repo, $course, $session) {
1897
            if (method_exists($repo, 'findByTypeInCourse')) {
1898
                return $repo->findByTypeInCourse($type, $course, $session);
1899
            }
1900
            $qb = $repo->getResourcesByCourse($course, $session)->andWhere('resource.descriptionType = :t')->setParameter('t', $type);
1901
            return $qb->getQuery()->getResult();
1902
        };
1903
1904
        $findByTitleInCourse = function (string $title) use ($repo, $course, $session) {
1905
            $qb = $repo->getResourcesByCourse($course, $session)
1906
                ->andWhere('resource.title = :t')
1907
                ->setParameter('t', $title)
1908
                ->setMaxResults(1);
1909
            return $qb->getQuery()->getOneOrNullResult();
1910
        };
1911
1912
        foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $oldId => $cd) {
1913
            $mapped = (int)($cd->destination_id ?? 0);
1914
            if ($mapped > 0) {
1915
                $this->dlog('restore_course_descriptions: already mapped, skipping', [
1916
                    'src_id' => (int)$oldId,
1917
                    'dst_id' => $mapped,
1918
                ]);
1919
                continue;
1920
            }
1921
1922
            $rawTitle   = (string)($cd->title ?? '');
1923
            $rawContent = (string)($cd->content ?? '');
1924
            $type       = (int)($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION);
1925
            $title      = trim($rawTitle) !== '' ? trim($rawTitle) : $rawTitle;
1926
            $content    = $rewriteContent($rawContent);
1927
1928
            $existingByType = $findByTypeInCourse($type);
1929
            $existingOne    = $existingByType[0] ?? null;
1930
1931
            if ($existingOne) {
1932
                switch ($this->file_option) {
1933
                    case FILE_SKIP:
1934
                        $destIid = (int)$existingOne->getIid();
1935
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass();
1936
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
1937
1938
                        $this->dlog('restore_course_descriptions: reuse (SKIP)', [
1939
                            'src_id' => (int)$oldId,
1940
                            'dst_id' => $destIid,
1941
                            'type'   => $type,
1942
                            'title'  => (string)$existingOne->getTitle(),
1943
                        ]);
1944
                        break;
1945
1946
                    case FILE_OVERWRITE:
1947
                        $existingOne
1948
                            ->setTitle($title !== '' ? $title : (string)$existingOne->getTitle())
1949
                            ->setContent($content)
1950
                            ->setDescriptionType($type)
1951
                            ->setProgress((int)($cd->progress ?? 0));
1952
                        $existingOne->setParent($course)->addCourseLink($course, $session);
1953
1954
                        $em->persist($existingOne);
1955
                        $em->flush();
1956
1957
                        $destIid = (int)$existingOne->getIid();
1958
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass();
1959
                        $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
1960
1961
                        $this->dlog('restore_course_descriptions: overwrite', [
1962
                            'src_id' => (int)$oldId,
1963
                            'dst_id' => $destIid,
1964
                            'type'   => $type,
1965
                            'title'  => (string)$existingOne->getTitle(),
1966
                        ]);
1967
                        break;
1968
1969
                    case FILE_RENAME:
1970
                    default:
1971
                        $base = $title !== '' ? $title : (string)($cd->extra['title'] ?? 'Description');
1972
                        $i = 1;
1973
                        $candidate = $base;
1974
                        while ($findByTitleInCourse($candidate)) {
1975
                            $i++;
1976
                            $candidate = $base.' ('.$i.')';
1977
                        }
1978
                        $title = $candidate;
1979
                        break;
1980
                }
1981
            }
1982
1983
            $entity = (new CCourseDescription())
1984
                ->setTitle($title)
1985
                ->setContent($content)
1986
                ->setDescriptionType($type)
1987
                ->setProgress((int)($cd->progress ?? 0))
1988
                ->setParent($course)
1989
                ->addCourseLink($course, $session);
1990
1991
            $em->persist($entity);
1992
            $em->flush();
1993
1994
            $destIid = (int)$entity->getIid();
1995
1996
            if (!isset($this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId])) {
1997
                $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] = new \stdClass();
1998
            }
1999
            $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid;
2000
2001
            $this->dlog('restore_course_descriptions: created', [
2002
                'src_id' => (int)$oldId,
2003
                'dst_id' => $destIid,
2004
                'type'   => $type,
2005
                'title'  => $title,
2006
            ]);
2007
        }
2008
2009
        $this->dlog('restore_course_descriptions: end');
2010
    }
2011
2012
    private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string
2013
    {
2014
        $node = $att->getResourceNode();
2015
        if (!$node) return null;
2016
2017
        $file = $node->getFirstResourceFile();
2018
        if (!$file) return null;
2019
2020
        /** @var ResourceNodeRepository $rnRepo */
2021
        $rnRepo = Container::$container->get(ResourceNodeRepository::class);
2022
        $rel    = $rnRepo->getFilename($file);
2023
        if (!$rel) return null;
2024
2025
        $abs = $this->projectUploadBase().$rel;
2026
        return is_readable($abs) ? $abs : null;
2027
    }
2028
2029
    public function restore_announcements($sessionId = 0)
2030
    {
2031
        if (!$this->course->has_resources(RESOURCE_ANNOUNCEMENT)) {
2032
            return;
2033
        }
2034
2035
        $sessionId = (int) $sessionId;
2036
        $resources = $this->course->resources;
2037
2038
        $count = is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null)
2039
            ? count($resources[RESOURCE_ANNOUNCEMENT])
2040
            : 0;
2041
2042
        $this->dlog('restore_announcements: begin', ['count' => $count]);
2043
2044
        /** @var EntityManagerInterface $em */
2045
        $em         = \Database::getManager();
2046
        $course     = api_get_course_entity($this->destination_course_id);
2047
        $session    = api_get_session_entity($sessionId);
2048
        $group      = api_get_group_entity();
2049
        $annRepo    = Container::getAnnouncementRepository();
2050
        $attachRepo = Container::getAnnouncementAttachmentRepository();
2051
2052
        $rewriteContent = function (string $html) {
2053
            if ($html === '') return '';
2054
            try {
2055
                if (class_exists(ChamiloHelper::class)
2056
                    && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) {
2057
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
2058
                        $html,
2059
                        api_get_course_entity($this->destination_course_id),
2060
                        $this->course->backup_path ?? '',
2061
                        array_filter([
2062
                            $this->course->destination_path ?? '',
2063
                            (string)($this->course->info['path'] ?? ''),
0 ignored issues
show
Bug introduced by
The property info does not seem to exist on Chamilo\CourseBundle\Component\CourseCopy\Course.
Loading history...
2064
                        ])
2065
                    );
2066
                }
2067
            } catch (\Throwable $e) {
2068
                error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage());
2069
            }
2070
2071
            $out = \DocumentManager::replaceUrlWithNewCourseCode(
2072
                $html,
2073
                $this->course->code,
2074
                $this->course->destination_path,
2075
                $this->course->backup_path,
2076
                $this->course->info['path']
2077
            );
2078
2079
            return $out === false ? '' : $out;
2080
        };
2081
2082
        $findExistingByTitle = function (string $title) use ($annRepo, $course, $session) {
2083
            $qb = $annRepo->getResourcesByCourse($course, $session);
2084
            $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1);
2085
            return $qb->getQuery()->getOneOrNullResult();
2086
        };
2087
2088
        $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/';
2089
2090
        foreach ($resources[RESOURCE_ANNOUNCEMENT] as $oldId => $a) {
2091
            $mapped = (int)($a->destination_id ?? 0);
2092
            if ($mapped > 0) {
2093
                $this->dlog('restore_announcements: already mapped, skipping', [
2094
                    'src_id' => (int)$oldId, 'dst_id' => $mapped
2095
                ]);
2096
                continue;
2097
            }
2098
2099
            $title = trim((string)($a->title ?? ''));
2100
            if ($title === '') { $title = 'Announcement'; }
2101
2102
            $contentHtml = (string)($a->content ?? '');
2103
            $contentHtml = $rewriteContent($contentHtml);
2104
2105
            $endDate = null;
2106
            try {
2107
                $rawDate = (string)($a->date ?? '');
2108
                if ($rawDate !== '') { $endDate = new \DateTime($rawDate); }
2109
            } catch (\Throwable $e) { $endDate = null; }
2110
2111
            $emailSent = (bool)($a->email_sent ?? false);
2112
2113
            $existing = $findExistingByTitle($title);
2114
            if ($existing) {
2115
                switch ($this->file_option) {
2116
                    case FILE_SKIP:
2117
                        $destId = (int)$existing->getIid();
2118
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass();
2119
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2120
                        $this->dlog('restore_announcements: reuse (SKIP)', [
2121
                            'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle()
2122
                        ]);
2123
                        break;
2124
2125
                    case FILE_OVERWRITE:
2126
                        $existing
2127
                            ->setTitle($title)
2128
                            ->setContent($contentHtml)
2129
                            ->setParent($course)
2130
                            ->addCourseLink($course, $session, $group)
2131
                            ->setEmailSent($emailSent);
2132
                        if ($endDate instanceof \DateTimeInterface) { $existing->setEndDate($endDate); }
2133
                        $em->persist($existing);
2134
                        $em->flush();
2135
2136
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass();
2137
                        $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = (int)$existing->getIid();
2138
2139
                        $this->dlog('restore_announcements: overwrite', [
2140
                            'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title
2141
                        ]);
2142
2143
                        $this->restoreAnnouncementAttachments($a, $existing, $originPath, $attachRepo, $em);
2144
                        continue 2;
2145
2146
                    case FILE_RENAME:
2147
                    default:
2148
                        $base = $title; $i = 1; $candidate = $base;
2149
                        while ($findExistingByTitle($candidate)) { $i++; $candidate = $base.' ('.$i.')'; }
2150
                        $title = $candidate;
2151
                        break;
2152
                }
2153
            }
2154
2155
            $entity = (new CAnnouncement())
2156
                ->setTitle($title)
2157
                ->setContent($contentHtml)
2158
                ->setParent($course)
2159
                ->addCourseLink($course, $session, $group)
2160
                ->setEmailSent($emailSent);
2161
            if ($endDate instanceof \DateTimeInterface) { $entity->setEndDate($endDate); }
2162
2163
            $em->persist($entity);
2164
            $em->flush();
2165
2166
            $destId = (int)$entity->getIid();
2167
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass();
2168
            $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId;
2169
2170
            $this->dlog('restore_announcements: created', [
2171
                'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title
2172
            ]);
2173
2174
            $this->restoreAnnouncementAttachments($a, $entity, $originPath, $attachRepo, $em);
2175
        }
2176
2177
        $this->dlog('restore_announcements: end');
2178
    }
2179
2180
    private function restoreAnnouncementAttachments(
2181
        object $a,
2182
        CAnnouncement $entity,
2183
        string $originPath,
2184
        $attachRepo,
2185
        EntityManagerInterface $em
2186
    ): void {
2187
        $copyMode = empty($this->course->backup_path);
2188
2189
        if ($copyMode) {
2190
            $srcAttachmentIds = [];
2191
            if (!empty($a->attachment_source_id)) { $srcAttachmentIds[] = (int)$a->attachment_source_id; }
2192
            if (!empty($a->attachment_source_ids) && is_array($a->attachment_source_ids)) {
2193
                foreach ($a->attachment_source_ids as $sid) { $sid = (int)$sid; if ($sid > 0) $srcAttachmentIds[] = $sid; }
2194
            }
2195
            if (empty($srcAttachmentIds) && !empty($a->source_id)) {
2196
                $srcAnn = Container::getAnnouncementRepository()->find((int)$a->source_id);
2197
                if ($srcAnn) {
2198
                    $srcAtts = Container::getAnnouncementAttachmentRepository()->findBy(['announcement' => $srcAnn]);
2199
                    foreach ($srcAtts as $sa) { $srcAttachmentIds[] = (int)$sa->getIid(); }
2200
                }
2201
            }
2202
2203
            if (!empty($srcAttachmentIds)) {
2204
                $attRepo = Container::getAnnouncementAttachmentRepository();
2205
2206
                foreach (array_unique($srcAttachmentIds) as $sid) {
2207
                    /** @var CAnnouncementAttachment|null $srcAtt */
2208
                    $srcAtt = $attRepo->find($sid);
2209
                    if (!$srcAtt) { continue; }
2210
2211
                    $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt);
2212
                    if (!$abs) {
2213
                        $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]);
2214
                        continue;
2215
                    }
2216
2217
                    $filename = $srcAtt->getFilename() ?: basename($abs);
2218
                    foreach ($entity->getAttachments() as $existingA) {
2219
                        if ($existingA->getFilename() === $filename) {
2220
                            if ($this->file_option === FILE_SKIP) { continue 2; }
2221
                            if ($this->file_option === FILE_RENAME) {
2222
                                $pi = pathinfo($filename);
2223
                                $base = $pi['filename'] ?? $filename;
2224
                                $ext  = isset($pi['extension']) && $pi['extension'] !== '' ? ('.'.$pi['extension']) : '';
2225
                                $i = 1; $candidate = $filename;
2226
                                $existingNames = array_map(fn($x) => $x->getFilename(), iterator_to_array($entity->getAttachments()));
2227
                                while (in_array($candidate, $existingNames, true)) { $candidate = $base.'_'.$i.$ext; $i++; }
2228
                                $filename = $candidate;
2229
                            }
2230
                        }
2231
                    }
2232
2233
                    $newAtt = (new CAnnouncementAttachment())
2234
                        ->setFilename($filename)
2235
                        ->setComment((string)$srcAtt->getComment())
2236
                        ->setSize((int)$srcAtt->getSize())
2237
                        ->setPath(uniqid('announce_', true))
2238
                        ->setAnnouncement($entity)
2239
                        ->setParent($entity)
2240
                        ->addCourseLink(
2241
                            api_get_course_entity($this->destination_course_id),
2242
                            api_get_session_entity(0),
2243
                            api_get_group_entity()
2244
                        );
2245
2246
                    $em->persist($newAtt);
2247
                    $em->flush();
2248
2249
                    if (method_exists($attachRepo, 'addFileFromLocalPath')) {
2250
                        $attachRepo->addFileFromLocalPath($newAtt, $abs);
2251
                    } else {
2252
                        $tmp = tempnam(sys_get_temp_dir(), 'ann_');
2253
                        @copy($abs, $tmp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for copy(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

2253
                        /** @scrutinizer ignore-unhandled */ @copy($abs, $tmp);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
2254
                        $_FILES['user_upload'] = [
2255
                            'name'     => $filename,
2256
                            'type'     => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream',
2257
                            'tmp_name' => $tmp,
2258
                            'error'    => 0,
2259
                            'size'     => filesize($tmp) ?: (int)$srcAtt->getSize(),
2260
                        ];
2261
                        $attachRepo->addFileFromFileRequest($newAtt, 'user_upload');
2262
                        @unlink($tmp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

2262
                        /** @scrutinizer ignore-unhandled */ @unlink($tmp);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
2263
                    }
2264
2265
                    $this->dlog('restore_announcements: attachment copied from ResourceFile', [
2266
                        'dst_announcement_id' => (int)$entity->getIid(),
2267
                        'filename'            => $newAtt->getFilename(),
2268
                        'size'                => $newAtt->getSize(),
2269
                    ]);
2270
                }
2271
            }
2272
            return;
2273
        }
2274
2275
        $meta = null;
2276
        if (!empty($a->attachment_path)) {
2277
            $src = rtrim($originPath, '/').'/'.$a->attachment_path;
2278
            if (is_file($src) && is_readable($src)) {
2279
                $meta = [
2280
                    'src'      => $src,
2281
                    'filename' => (string)($a->attachment_filename ?? basename($src)),
2282
                    'comment'  => (string)($a->attachment_comment ?? ''),
2283
                    'size'     => (int)($a->attachment_size ?? (filesize($src) ?: 0)),
2284
                ];
2285
            }
2286
        }
2287
        if (!$meta && !empty($this->course->orig)) {
2288
            $table = \Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT);
2289
            $sql = 'SELECT path, comment, size, filename
2290
            FROM '.$table.'
2291
            WHERE c_id = '.$this->destination_course_id.'
2292
              AND announcement_id = '.(int)($a->source_id ?? 0);
2293
            $res = \Database::query($sql);
2294
            if ($row = \Database::fetch_object($res)) {
2295
                $src = rtrim($originPath, '/').'/'.$row->path;
2296
                if (is_file($src) && is_readable($src)) {
2297
                    $meta = [
2298
                        'src'      => $src,
2299
                        'filename' => (string)$row->filename,
2300
                        'comment'  => (string)$row->comment,
2301
                        'size'     => (int)$row->size,
2302
                    ];
2303
                }
2304
            }
2305
        }
2306
        if (!$meta) { return; }
2307
2308
        $attachment = (new CAnnouncementAttachment())
2309
            ->setFilename($meta['filename'])
2310
            ->setPath(uniqid('announce_', true))
2311
            ->setComment($meta['comment'])
2312
            ->setSize($meta['size'])
2313
            ->setAnnouncement($entity)
2314
            ->setParent($entity)
2315
            ->addCourseLink(
2316
                api_get_course_entity($this->destination_course_id),
2317
                api_get_session_entity(0),
2318
                api_get_group_entity()
2319
            );
2320
2321
        $em->persist($attachment);
2322
        $em->flush();
2323
2324
        $tmp = tempnam(sys_get_temp_dir(), 'ann_');
2325
        @copy($meta['src'], $tmp);
2326
        $_FILES['user_upload'] = [
2327
            'name'     => $meta['filename'],
2328
            'type'     => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream',
2329
            'tmp_name' => $tmp,
2330
            'error'    => 0,
2331
            'size'     => filesize($tmp) ?: $meta['size'],
2332
        ];
2333
        $attachRepo->addFileFromFileRequest($attachment, 'user_upload');
2334
        @unlink($tmp);
2335
2336
        $this->dlog('restore_announcements: attachment stored (ZIP)', [
2337
            'announcement_id' => (int)$entity->getIid(),
2338
            'filename'        => $attachment->getFilename(),
2339
            'size'            => $attachment->getSize(),
2340
        ]);
2341
    }
2342
2343
    public function restore_quizzes($session_id = 0, $respect_base_content = false)
2344
    {
2345
        if (!$this->course->has_resources(RESOURCE_QUIZ)) {
2346
            error_log('RESTORE_QUIZ: No quiz resources in backup.');
2347
            return;
2348
        }
2349
2350
        $em            = Database::getManager();
2351
        $resources     = $this->course->resources;
2352
        $courseEntity  = api_get_course_entity($this->destination_course_id);
2353
        $sessionEntity = !empty($session_id) ? api_get_session_entity((int)$session_id) : api_get_session_entity();
2354
2355
        $rewrite = function (?string $html) use ($courseEntity) {
2356
            if ($html === null || $html === false) return '';
2357
            if (class_exists(ChamiloHelper::class)
2358
                && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) {
2359
                try {
2360
                    $backupRoot = $this->course->backup_path ?? '';
2361
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets($html, $courseEntity, $backupRoot);
2362
                } catch (\Throwable $e) {
2363
                    error_log('RESTORE_QUIZ: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage());
2364
                    return $html;
2365
                }
2366
            }
2367
            return $html;
2368
        };
2369
2370
        if (empty($this->course->resources[RESOURCE_QUIZQUESTION])
2371
            && !empty($this->course->resources['Exercise_Question'])) {
2372
            $this->course->resources[RESOURCE_QUIZQUESTION] = $this->course->resources['Exercise_Question'];
2373
            $resources = $this->course->resources;
2374
            error_log('RESTORE_QUIZ: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
2375
        }
2376
2377
        foreach ($resources[RESOURCE_QUIZ] as $id => $quizWrap) {
2378
            $quiz = isset($quizWrap->obj) ? $quizWrap->obj : $quizWrap;
2379
2380
            $description      = $rewrite($quiz->description ?? '');
2381
            $quiz->start_time = ($quiz->start_time === '0000-00-00 00:00:00') ? null : ($quiz->start_time ?? null);
2382
            $quiz->end_time   = ($quiz->end_time   === '0000-00-00 00:00:00') ? null : ($quiz->end_time   ?? null);
2383
2384
            global $_custom;
2385
            if (!empty($_custom['exercises_clean_dates_when_restoring'])) {
2386
                $quiz->start_time = null;
2387
                $quiz->end_time   = null;
2388
            }
2389
2390
            if ((int)$id === -1) {
2391
                $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = -1;
2392
                error_log('RESTORE_QUIZ: Skipping virtual quiz (id=-1).');
2393
                continue;
2394
            }
2395
2396
            $entity = (new CQuiz())
2397
                ->setParent($courseEntity)
2398
                ->addCourseLink(
2399
                    $courseEntity,
2400
                    $respect_base_content ? $sessionEntity : (!empty($session_id) ? $sessionEntity : api_get_session_entity()),
2401
                    api_get_group_entity()
2402
                )
2403
                ->setTitle((string) $quiz->title)
2404
                ->setDescription($description)
2405
                ->setType(isset($quiz->quiz_type) ? (int) $quiz->quiz_type : (int) $quiz->type)
2406
                ->setRandom((int) $quiz->random)
2407
                ->setRandomAnswers((bool) $quiz->random_answers)
2408
                ->setResultsDisabled((int) $quiz->results_disabled)
2409
                ->setMaxAttempt((int) $quiz->max_attempt)
2410
                ->setFeedbackType((int) $quiz->feedback_type)
2411
                ->setExpiredTime((int) $quiz->expired_time)
2412
                ->setReviewAnswers((int) $quiz->review_answers)
2413
                ->setRandomByCategory((int) $quiz->random_by_category)
2414
                ->setTextWhenFinished((string) ($quiz->text_when_finished ?? ''))
2415
                ->setTextWhenFinishedFailure((string) ($quiz->text_when_finished_failure ?? ''))
2416
                ->setDisplayCategoryName((int) ($quiz->display_category_name ?? 0))
2417
                ->setSaveCorrectAnswers(isset($quiz->save_correct_answers) ? (int) $quiz->save_correct_answers : 0)
2418
                ->setPropagateNeg((int) $quiz->propagate_neg)
2419
                ->setHideQuestionTitle((bool) ($quiz->hide_question_title ?? false))
2420
                ->setHideQuestionNumber((int) ($quiz->hide_question_number ?? 0))
2421
                ->setStartTime(!empty($quiz->start_time) ? new \DateTime($quiz->start_time) : null)
2422
                ->setEndTime(!empty($quiz->end_time) ? new \DateTime($quiz->end_time) : null);
2423
2424
            if (isset($quiz->access_condition) && $quiz->access_condition !== '') {
2425
                $entity->setAccessCondition((string)$quiz->access_condition);
2426
            }
2427
            if (isset($quiz->pass_percentage) && $quiz->pass_percentage !== '' && $quiz->pass_percentage !== null) {
2428
                $entity->setPassPercentage((int)$quiz->pass_percentage);
2429
            }
2430
            if (isset($quiz->question_selection_type) && $quiz->question_selection_type !== '' && $quiz->question_selection_type !== null) {
2431
                $entity->setQuestionSelectionType((int)$quiz->question_selection_type);
2432
            }
2433
            if ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')) {
2434
                $entity->setNotifications((string)($quiz->notifications ?? ''));
2435
            }
2436
2437
            $em->persist($entity);
2438
            $em->flush();
2439
2440
            $newQuizId = (int)$entity->getIid();
2441
            $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $newQuizId;
2442
2443
            $qCount = isset($quiz->question_ids) ? count((array)$quiz->question_ids) : 0;
2444
            error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string)$quiz->title.'" with '.$qCount.' question ids.');
2445
2446
            $order = 0;
2447
            if (!empty($quiz->question_ids)) {
2448
                foreach ($quiz->question_ids as $index => $question_id) {
2449
                    $qid = $this->restore_quiz_question($question_id);
2450
                    if (!$qid) {
2451
                        error_log('RESTORE_QUIZ: restore_quiz_question returned 0 for src_question_id='.$question_id);
2452
                        continue;
2453
                    }
2454
2455
                    $question_order = !empty($quiz->question_orders[$index])
2456
                        ? (int)$quiz->question_orders[$index]
2457
                        : $order;
2458
2459
                    $order++;
2460
2461
                    $questionEntity = $em->getRepository(CQuizQuestion::class)->find($qid);
2462
                    if (!$questionEntity) {
2463
                        error_log('RESTORE_QUIZ: Question entity not found after insert. qid='.$qid);
2464
                        continue;
2465
                    }
2466
2467
                    $rel = (new CQuizRelQuestion())
2468
                        ->setQuiz($entity)
2469
                        ->setQuestion($questionEntity)
2470
                        ->setQuestionOrder($question_order);
2471
2472
                    $em->persist($rel);
2473
                    $em->flush();
2474
                }
2475
            } else {
2476
                error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string)$quiz->title.'").');
2477
            }
2478
        }
2479
    }
2480
2481
2482
    /**
2483
     * Restore quiz-questions. Returns new question IID.
2484
     */
2485
    public function restore_quiz_question($id)
2486
    {
2487
        $em        = Database::getManager();
2488
        $resources = $this->course->resources;
2489
2490
        if (empty($resources[RESOURCE_QUIZQUESTION]) && !empty($resources['Exercise_Question'])) {
2491
            $resources[RESOURCE_QUIZQUESTION] = $this->course->resources[RESOURCE_QUIZQUESTION]
2492
                = $this->course->resources['Exercise_Question'];
2493
            error_log('RESTORE_QUESTION: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.');
2494
        }
2495
2496
        /** @var object|null $question */
2497
        $question = $resources[RESOURCE_QUIZQUESTION][$id] ?? null;
2498
        if (!is_object($question)) {
2499
            error_log('RESTORE_QUESTION: Question not found in resources. src_id='.$id);
2500
            return 0;
2501
        }
2502
        if (method_exists($question, 'is_restored') && $question->is_restored()) {
2503
            return (int)$question->destination_id;
2504
        }
2505
2506
        $courseEntity = api_get_course_entity($this->destination_course_id);
2507
2508
        $rewrite = function (?string $html) use ($courseEntity) {
2509
            if ($html === null || $html === false) return '';
2510
            if (class_exists(ChamiloHelper::class)
2511
                && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) {
2512
                try {
2513
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity, null);
2514
                } catch (\ArgumentCountError $e) {
2515
                    return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity);
0 ignored issues
show
Bug introduced by
The call to Chamilo\CoreBundle\Helpe...acyCourseUrlsToAssets() has too few arguments starting with backupRoot. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

2515
                    return ChamiloHelper::/** @scrutinizer ignore-call */ rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity);

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
2516
                } catch (\Throwable $e) {
2517
                    error_log('RESTORE_QUESTION: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage());
2518
                    return $html;
2519
                }
2520
            }
2521
            return $html;
2522
        };
2523
2524
        $question->description = $rewrite($question->description ?? '');
2525
        $question->question    = $rewrite($question->question ?? '');
2526
2527
        $imageNewId = '';
2528
        if (!empty($question->picture)) {
2529
            if (isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture])) {
2530
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]['destination_id'];
2531
            } elseif (isset($resources[RESOURCE_DOCUMENT][$question->picture])) {
2532
                $imageNewId = (string) $resources[RESOURCE_DOCUMENT][$question->picture]->destination_id;
2533
            }
2534
        }
2535
2536
        $qType  = (int) ($question->quiz_type ?? $question->type);
2537
        $entity = (new CQuizQuestion())
2538
            ->setParent($courseEntity)
2539
            ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity())
2540
            ->setQuestion($question->question)
2541
            ->setDescription($question->description)
2542
            ->setPonderation((float) ($question->ponderation ?? 0))
2543
            ->setPosition((int) ($question->position ?? 1))
2544
            ->setType($qType)
2545
            ->setPicture($imageNewId)
2546
            ->setLevel((int) ($question->level ?? 1))
2547
            ->setExtra((string) ($question->extra ?? ''));
2548
2549
        $em->persist($entity);
2550
        $em->flush();
2551
2552
        $new_id = (int)$entity->getIid();
2553
        if (!$new_id) {
2554
            error_log('RESTORE_QUESTION: Failed to obtain new question iid for src_id='.$id);
2555
            return 0;
2556
        }
2557
2558
        $answers = (array)($question->answers ?? []);
2559
        error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.count($answers));
2560
2561
        $isMatchingFamily  = in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true);
2562
        $correctMapSrcToDst = []; // dstAnsId => srcCorrectRef
2563
        $allSrcAnswersById  = []; // srcAnsId => text
2564
        $dstAnswersByIdText = []; // dstAnsId => text
2565
2566
        if ($isMatchingFamily) {
2567
            foreach ($answers as $a) {
2568
                $allSrcAnswersById[$a['id']] = $rewrite($a['answer'] ?? '');
2569
            }
2570
        }
2571
2572
        foreach ($answers as $a) {
2573
            $ansText = $rewrite($a['answer'] ?? '');
2574
            $comment = $rewrite($a['comment'] ?? '');
2575
2576
            $ans = (new CQuizAnswer())
2577
                ->setQuestion($entity)
2578
                ->setAnswer((string)$ansText)
2579
                ->setComment((string)$comment)
2580
                ->setPonderation((float)($a['ponderation'] ?? 0))
2581
                ->setPosition((int)($a['position'] ?? 0))
2582
                ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string)$a['hotspot_coordinates'] : null)
2583
                ->setHotspotType(isset($a['hotspot_type']) ? (string)$a['hotspot_type'] : null);
2584
2585
            if (isset($a['correct']) && $a['correct'] !== '' && $a['correct'] !== null) {
2586
                $ans->setCorrect((int)$a['correct']);
2587
            }
2588
2589
            $em->persist($ans);
2590
            $em->flush();
2591
2592
            if ($isMatchingFamily) {
2593
                $correctMapSrcToDst[(int)$ans->getIid()] = $a['correct'] ?? null;
2594
                $dstAnswersByIdText[(int)$ans->getIid()] = $ansText;
2595
            }
2596
        }
2597
2598
        if ($isMatchingFamily && $correctMapSrcToDst) {
2599
            foreach ($entity->getAnswers() as $dstAns) {
2600
                $dstAid = (int)$dstAns->getIid();
2601
                $srcRef = $correctMapSrcToDst[$dstAid] ?? null;
2602
                if ($srcRef === null) continue;
2603
2604
                if (isset($allSrcAnswersById[$srcRef])) {
2605
                    $needle = $allSrcAnswersById[$srcRef];
2606
                    $newDst = null;
2607
                    foreach ($dstAnswersByIdText as $candId => $txt) {
2608
                        if ($txt === $needle) { $newDst = $candId; break; }
2609
                    }
2610
                    if ($newDst !== null) {
2611
                        $dstAns->setCorrect((int)$newDst);
2612
                        $em->persist($dstAns);
2613
                    }
2614
                }
2615
            }
2616
            $em->flush();
2617
        }
2618
2619
        if (defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) {
2620
            $newOptByOld = [];
2621
            if (isset($question->question_options) && is_iterable($question->question_options)) {
2622
                foreach ($question->question_options as $optWrap) {
2623
                    $opt = $optWrap->obj ?? $optWrap;
2624
                    $optEntity = (new CQuizQuestionOption())
2625
                        ->setQuestion($entity)
2626
                        ->setTitle((string)$opt->name)
2627
                        ->setPosition((int)$opt->position);
2628
                    $em->persist($optEntity);
2629
                    $em->flush();
2630
                    $newOptByOld[$opt->id] = (int)$optEntity->getIid();
2631
                }
2632
                foreach ($entity->getAnswers() as $dstAns) {
2633
                    $corr = $dstAns->getCorrect();
2634
                    if ($corr !== null && isset($newOptByOld[$corr])) {
2635
                        $dstAns->setCorrect((int)$newOptByOld[$corr]);
2636
                        $em->persist($dstAns);
2637
                    }
2638
                }
2639
                $em->flush();
2640
            }
2641
        }
2642
2643
        $this->course->resources[RESOURCE_QUIZQUESTION][$id]->destination_id = $new_id;
2644
2645
        return $new_id;
2646
    }
2647
2648
    public function restore_surveys($sessionId = 0)
2649
    {
2650
        if (!$this->course->has_resources(RESOURCE_SURVEY)) {
2651
            $this->debug && error_log('COURSE_DEBUG: restore_surveys: no survey resources in backup.');
2652
            return;
2653
        }
2654
2655
        $em            = Database::getManager();
2656
        $surveyRepo    = Container::getSurveyRepository();
2657
        $courseEntity  = api_get_course_entity($this->destination_course_id);
2658
        $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null;
2659
2660
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
2661
        if ($backupRoot === '') {
2662
            $this->debug && error_log('COURSE_DEBUG: restore_surveys: backupRoot empty; URL rewriting may be partial.');
2663
        }
2664
2665
        $resources = $this->course->resources;
2666
2667
        foreach ($resources[RESOURCE_SURVEY] as $legacySurveyId => $surveyObj) {
2668
            try {
2669
                $code = (string)($surveyObj->code ?? '');
2670
                $lang = (string)($surveyObj->lang ?? '');
2671
2672
                $title        = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->title ?? ''),        $courseEntity, $backupRoot) ?? (string)($surveyObj->title ?? '');
2673
                $subtitle     = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->subtitle ?? ''),     $courseEntity, $backupRoot) ?? (string)($surveyObj->subtitle ?? '');
2674
                $intro        = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->intro ?? ''),        $courseEntity, $backupRoot) ?? (string)($surveyObj->intro ?? '');
2675
                $surveyThanks = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->surveythanks ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->surveythanks ?? '');
2676
2677
                $onePerPage = !empty($surveyObj->one_question_per_page);
2678
                $shuffle    = isset($surveyObj->shuffle) ? (bool)$surveyObj->shuffle : (!empty($surveyObj->suffle));
2679
                $anonymous  = (string)((int)($surveyObj->anonymous ?? 0));
2680
2681
                try { $creationDate = !empty($surveyObj->creation_date) ? new \DateTime((string)$surveyObj->creation_date) : new \DateTime(); } catch (\Throwable) { $creationDate = new \DateTime(); }
2682
                try { $availFrom    = !empty($surveyObj->avail_from)    ? new \DateTime((string)$surveyObj->avail_from)    : null; } catch (\Throwable) { $availFrom = null; }
2683
                try { $availTill    = !empty($surveyObj->avail_till)    ? new \DateTime((string)$surveyObj->avail_till)    : null; } catch (\Throwable) { $availTill = null; }
2684
2685
                $visibleResults        = isset($surveyObj->visible_results) ? (int)$surveyObj->visible_results : null;
2686
                $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool)$surveyObj->display_question_number : true;
2687
2688
                $existing = null;
2689
                try {
2690
                    if (method_exists($surveyRepo, 'findOneByCodeAndLangInCourse')) {
2691
                        $existing = $surveyRepo->findOneByCodeAndLangInCourse($courseEntity, $code, $lang);
2692
                    } else {
2693
                        $existing = $surveyRepo->findOneBy(['code' => $code, 'lang' => $lang]);
2694
                    }
2695
                } catch (\Throwable $e) {
2696
                    $this->debug && error_log('COURSE_DEBUG: restore_surveys: duplicate check skipped: '.$e->getMessage());
2697
                }
2698
2699
                if ($existing instanceof CSurvey) {
2700
                    switch ($this->file_option) {
2701
                        case FILE_SKIP:
2702
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid();
2703
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: survey exists code='$code' (skip).");
2704
                            continue 2;
2705
2706
                        case FILE_RENAME:
2707
                            $base = $code.'_';
2708
                            $i    = 1;
2709
                            $try  = $base.$i;
2710
                            while (!$this->is_survey_code_available($try)) {
2711
                                $try = $base.(++$i);
2712
                            }
2713
                            $code = $try;
2714
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: renaming to '$code'.");
2715
                            break;
2716
2717
                        case FILE_OVERWRITE:
2718
                            \SurveyManager::deleteSurvey($existing);
2719
                            $em->flush();
2720
                            $this->debug && error_log("COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite).");
2721
                            break;
2722
2723
                        default:
2724
                            $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid();
2725
                            continue 2;
2726
                    }
2727
                }
2728
2729
                // --- Create survey ---
2730
                $newSurvey = new CSurvey();
2731
                $newSurvey
2732
                    ->setCode($code)
2733
                    ->setTitle($title)
2734
                    ->setSubtitle($subtitle)
2735
                    ->setLang($lang)
2736
                    ->setAvailFrom($availFrom)
2737
                    ->setAvailTill($availTill)
2738
                    ->setIsShared((string)($surveyObj->is_shared ?? '0'))
2739
                    ->setTemplate((string)($surveyObj->template ?? 'template'))
2740
                    ->setIntro($intro)
2741
                    ->setSurveythanks($surveyThanks)
2742
                    ->setCreationDate($creationDate)
2743
                    ->setInvited(0)
2744
                    ->setAnswered(0)
2745
                    ->setInviteMail((string)($surveyObj->invite_mail ?? ''))
2746
                    ->setReminderMail((string)($surveyObj->reminder_mail ?? ''))
2747
                    ->setOneQuestionPerPage($onePerPage)
2748
                    ->setShuffle($shuffle)
2749
                    ->setAnonymous($anonymous)
2750
                    ->setDisplayQuestionNumber($displayQuestionNumber);
2751
2752
                if (method_exists($newSurvey, 'setParent')) {
2753
                    $newSurvey->setParent($courseEntity);
2754
                }
2755
                $newSurvey->addCourseLink($courseEntity, $sessionEntity);
2756
2757
                if (method_exists($surveyRepo, 'create')) {
2758
                    $surveyRepo->create($newSurvey);
2759
                } else {
2760
                    $em->persist($newSurvey);
2761
                    $em->flush();
2762
                }
2763
2764
                $newId = (int)$newSurvey->getIid();
2765
                $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = $newId;
2766
2767
                // --- Restore questions ---
2768
                $questionIds = is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : [];
2769
                if (empty($questionIds) && !empty($resources[RESOURCE_SURVEYQUESTION])) {
2770
                    foreach ($resources[RESOURCE_SURVEYQUESTION] as $qid => $qWrap) {
2771
                        $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2772
                        if ((int)($q->survey_id ?? 0) === (int)$legacySurveyId) {
2773
                            $questionIds[] = (int)$qid;
2774
                        }
2775
                    }
2776
                }
2777
2778
                foreach ($questionIds as $legacyQid) {
2779
                    $this->restore_survey_question((int)$legacyQid, $newId);
2780
                }
2781
2782
                $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".count($questionIds));
2783
            } catch (\Throwable $e) {
2784
                error_log('COURSE_DEBUG: restore_surveys: failed: '.$e->getMessage());
2785
            }
2786
        }
2787
    }
2788
2789
2790
    /**
2791
     * Restore survey-questions (legacy signature). $survey_id is the NEW iid.
2792
     */
2793
    public function restore_survey_question($id, $survey_id)
2794
    {
2795
        $resources = $this->course->resources;
2796
        $qWrap     = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null;
2797
2798
        if (!$qWrap || !is_object($qWrap)) {
2799
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: legacy question $id not found.");
2800
            return 0;
2801
        }
2802
        if (method_exists($qWrap, 'is_restored') && $qWrap->is_restored()) {
2803
            return $qWrap->destination_id;
2804
        }
2805
2806
        $surveyRepo   = Container::getSurveyRepository();
2807
        $em           = Database::getManager();
2808
        $courseEntity = api_get_course_entity($this->destination_course_id);
2809
2810
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
2811
2812
        $survey = $surveyRepo->find((int)$survey_id);
2813
        if (!$survey instanceof CSurvey) {
2814
            $this->debug && error_log("COURSE_DEBUG: restore_survey_question: target survey $survey_id not found.");
2815
            return 0;
2816
        }
2817
2818
        $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap;
2819
2820
        // Rewrite HTML
2821
        $questionText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question ?? '');
2822
        $commentText  = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question_comment ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question_comment ?? '');
2823
2824
        try {
2825
            $question = new CSurveyQuestion();
2826
            $question
2827
                ->setSurvey($survey)
2828
                ->setSurveyQuestion($questionText)
2829
                ->setSurveyQuestionComment($commentText)
2830
                ->setType((string)($q->survey_question_type ?? $q->type ?? 'open'))
2831
                ->setDisplay((string)($q->display ?? 'vertical'))
2832
                ->setSort((int)($q->sort ?? 0));
2833
2834
            if (isset($q->shared_question_id) && method_exists($question, 'setSharedQuestionId')) {
2835
                $question->setSharedQuestionId((int)$q->shared_question_id);
2836
            }
2837
            if (isset($q->max_value) && method_exists($question, 'setMaxValue')) {
2838
                $question->setMaxValue((int)$q->max_value);
2839
            }
2840
            if (isset($q->is_required)) {
2841
                if (method_exists($question, 'setIsMandatory')) {
2842
                    $question->setIsMandatory((bool)$q->is_required);
2843
                } elseif (method_exists($question, 'setIsRequired')) {
2844
                    $question->setIsRequired((bool)$q->is_required);
2845
                }
2846
            }
2847
2848
            $em->persist($question);
2849
            $em->flush();
2850
2851
            // Options (value NOT NULL: default to 0 if missing)
2852
            $answers = is_array($q->answers ?? null) ? $q->answers : [];
2853
            foreach ($answers as $idx => $answer) {
2854
                $optText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($answer['option_text'] ?? ''), $courseEntity, $backupRoot) ?? (string)($answer['option_text'] ?? '');
2855
                $value   = isset($answer['value']) && $answer['value'] !== null ? (int)$answer['value'] : 0;
2856
                $sort    = (int)($answer['sort'] ?? ($idx + 1));
2857
2858
                $opt = new CSurveyQuestionOption();
2859
                $opt
2860
                    ->setSurvey($survey)
2861
                    ->setQuestion($question)
2862
                    ->setOptionText($optText)
2863
                    ->setSort($sort)
2864
                    ->setValue($value);
2865
2866
                $em->persist($opt);
2867
            }
2868
            $em->flush();
2869
2870
            $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int)$question->getIid();
2871
2872
            return (int)$question->getIid();
2873
        } catch (\Throwable $e) {
2874
            error_log('COURSE_DEBUG: restore_survey_question: failed: '.$e->getMessage());
2875
            return 0;
2876
        }
2877
    }
2878
2879
2880
    public function is_survey_code_available($survey_code)
2881
    {
2882
        $survey_code = (string)$survey_code;
2883
        $surveyRepo  = Container::getSurveyRepository();
2884
2885
        try {
2886
            $hit = $surveyRepo->findOneBy(['code' => $survey_code]);
2887
            return $hit ? false : true;
2888
        } catch (\Throwable $e) {
2889
            $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage());
2890
            return true;
2891
        }
2892
    }
2893
2894
    /**
2895
     * @param int  $sessionId
2896
     * @param bool $baseContent
2897
     */
2898
    public function restore_learnpath_category(int $sessionId = 0, bool $baseContent = false): void
2899
    {
2900
        $reuseExisting = false;
2901
        if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing']) &&
2902
            true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) {
2903
            $reuseExisting = true;
2904
        }
2905
2906
        if (!$this->course->has_resources(RESOURCE_LEARNPATH_CATEGORY)) {
2907
            return;
2908
        }
2909
2910
        $tblLpCategory = Database::get_course_table(TABLE_LP_CATEGORY);
2911
        $resources = $this->course->resources;
2912
2913
        /** @var LearnPathCategory $item */
2914
        foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) {
2915
            /** @var CLpCategory|null $lpCategory */
2916
            $lpCategory = $item->object;
2917
2918
            if (!$lpCategory) {
2919
                continue;
2920
            }
2921
2922
            $title = trim($lpCategory->getTitle());
2923
            if ($title === '') {
2924
                continue;
2925
            }
2926
2927
            $categoryId = 0;
2928
2929
            $existing = Database::select(
2930
                'iid',
2931
                $tblLpCategory,
2932
                [
2933
                    'WHERE' => [
2934
                        'c_id = ? AND name = ?' => [$this->destination_course_id, $title],
2935
                    ],
2936
                ],
2937
                'first'
2938
            );
2939
2940
            if ($reuseExisting && !empty($existing) && !empty($existing['iid'])) {
2941
                $categoryId = (int) $existing['iid'];
2942
            } else {
2943
                $values = [
2944
                    'c_id' => $this->destination_course_id,
2945
                    'name' => $title,
2946
                ];
2947
2948
                $categoryId = (int) learnpath::createCategory($values);
2949
            }
2950
2951
            if ($categoryId > 0) {
2952
                $this->course->resources[RESOURCE_LEARNPATH_CATEGORY][$id]->destination_id = $categoryId;
2953
            }
2954
        }
2955
    }
2956
2957
    /**
2958
     * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP.
2959
     * Returns absolute path to the temp ZIP or null on error.
2960
     */
2961
    private function zipScormFolder(string $folderAbs): ?string
2962
    {
2963
        $folderAbs = rtrim($folderAbs, '/');
2964
        $manifest = $folderAbs.'/imsmanifest.xml';
2965
        if (!is_file($manifest)) {
2966
            error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs");
2967
            return null;
2968
        }
2969
2970
        $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip';
2971
2972
        try {
2973
            $zip = new ZipFile();
2974
            // Put folder contents at the ZIP root – important for SCORM imports
2975
            $zip->addDirRecursive($folderAbs, '');
2976
            $zip->saveAsFile($tmpZip);
2977
            $zip->close();
2978
        } catch (\Throwable $e) {
2979
            error_log("SCORM ZIPPER: Failed to create temp zip: ".$e->getMessage());
2980
            return null;
2981
        }
2982
2983
        if (!is_file($tmpZip) || filesize($tmpZip) === 0) {
2984
            @unlink($tmpZip);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

2984
            /** @scrutinizer ignore-unhandled */ @unlink($tmpZip);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
2985
            error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip");
2986
            return null;
2987
        }
2988
2989
        return $tmpZip;
2990
    }
2991
2992
    /**
2993
     * Find a SCORM package for a given LP.
2994
     * It returns ['zip' => <abs path or null>, 'temp' => true if zip is temporary].
2995
     *
2996
     * Search order:
2997
     *  1) resources[SCORM] entries bound to this LP (zip or path).
2998
     *     - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly.
2999
     *  2) Heuristics: scan typical folders for *.zip
3000
     *  3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder.
3001
     */
3002
    private function findScormPackageForLp(int $srcLpId): array
3003
    {
3004
        $out = ['zip' => null, 'temp' => false];
3005
        $base = rtrim($this->course->backup_path, '/');
3006
3007
        // 1) Direct mapping from SCORM bucket
3008
        if (!empty($this->course->resources[RESOURCE_SCORM]) && is_array($this->course->resources[RESOURCE_SCORM])) {
3009
            foreach ($this->course->resources[RESOURCE_SCORM] as $sc) {
3010
                $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0;
3011
                $dst = isset($sc->lp_id_dest)   ? (int) $sc->lp_id_dest   : 0;
3012
                $match = ($src && $src === $srcLpId);
3013
3014
                if (
3015
                    !$match &&
3016
                    $dst &&
3017
                    !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id)
3018
                ) {
3019
                    $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id);
3020
                }
3021
                if (!$match) { continue; }
3022
3023
                $cands = [];
3024
                if (!empty($sc->zip))  { $cands[] = $base.'/'.ltrim((string) $sc->zip, '/'); }
3025
                if (!empty($sc->path)) { $cands[] = $base.'/'.ltrim((string) $sc->path, '/'); }
3026
3027
                foreach ($cands as $abs) {
3028
                    if (is_file($abs) && is_readable($abs)) {
3029
                        $out['zip']  = $abs;
3030
                        $out['temp'] = false;
3031
                        return $out;
3032
                    }
3033
                    if (is_dir($abs) && is_readable($abs)) {
3034
                        $tmp = $this->zipScormFolder($abs);
3035
                        if ($tmp) {
3036
                            $out['zip']  = $tmp;
3037
                            $out['temp'] = true;
3038
                            return $out;
3039
                        }
3040
                    }
3041
                }
3042
            }
3043
        }
3044
3045
        // 2) Heuristic: typical folders with *.zip
3046
        foreach (['/scorm','/document/scorm','/documents/scorm'] as $dir) {
3047
            $full = $base.$dir;
3048
            if (!is_dir($full)) { continue; }
3049
            $glob = glob($full.'/*.zip') ?: [];
3050
            if (!empty($glob)) {
3051
                $out['zip']  = $glob[0];
3052
                $out['temp'] = false;
3053
                return $out;
3054
            }
3055
        }
3056
3057
        // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder
3058
        $riiFlags = \FilesystemIterator::SKIP_DOTS;
3059
        try {
3060
            $rii = new \RecursiveIteratorIterator(
3061
                new \RecursiveDirectoryIterator($base, $riiFlags),
3062
                \RecursiveIteratorIterator::SELF_FIRST
3063
            );
3064
            foreach ($rii as $f) {
3065
                if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') {
3066
                    $folder = $f->getPath();
3067
                    $tmp = $this->zipScormFolder($folder);
3068
                    if ($tmp) {
3069
                        $out['zip']  = $tmp;
3070
                        $out['temp'] = true;
3071
                        return $out;
3072
                    }
3073
                }
3074
            }
3075
        } catch (\Throwable $e) {
3076
            error_log("SCORM FINDER: Recursive scan failed: ".$e->getMessage());
3077
        }
3078
3079
        return $out;
3080
    }
3081
3082
    /**
3083
     * Restore SCORM ZIPs under Documents (Learning paths) for traceability.
3084
     * Accepts real zips and on-the-fly temporary ones (temp will be deleted after upload).
3085
     */
3086
    public function restore_scorm_documents(): void
3087
    {
3088
        $logp = 'RESTORE_SCORM_ZIP: ';
3089
3090
        $getBucket = function(string $type) {
3091
            if (!empty($this->course->resources[$type]) && is_array($this->course->resources[$type])) {
3092
                return $this->course->resources[$type];
3093
            }
3094
            foreach ($this->course->resources ?? [] as $k => $v) {
3095
                if (is_string($k) && strtolower($k) === strtolower($type) && is_array($v)) {
3096
                    return $v;
3097
                }
3098
            }
3099
            return [];
3100
        };
3101
3102
        /** @var \Chamilo\CourseBundle\Repository\CDocumentRepository $docRepo */
3103
        $docRepo = Container::getDocumentRepository();
3104
        $em      = Database::getManager();
3105
3106
        $courseInfo = $this->destination_course_info;
3107
        if (empty($courseInfo) || empty($courseInfo['real_id'])) { error_log($logp.'missing courseInfo/real_id'); return; }
3108
3109
        $courseEntity = api_get_course_entity((int) $courseInfo['real_id']);
3110
        if (!$courseEntity) { error_log($logp.'api_get_course_entity failed'); return; }
3111
3112
        $sid = property_exists($this, 'current_session_id') ? (int) $this->current_session_id : 0;
3113
        $session = api_get_session_entity($sid);
3114
3115
        $entries = [];
3116
3117
        // A) direct SCORM bucket
3118
        $scormBucket = $getBucket(RESOURCE_SCORM);
3119
        foreach ($scormBucket as $sc) { $entries[] = $sc; }
3120
3121
        // B) also try LPs that are SCORM
3122
        $lpBucket = $getBucket(RESOURCE_LEARNPATH);
3123
        foreach ($lpBucket as $srcLpId => $lpObj) {
3124
            $lpType = (int)($lpObj->lp_type ?? $lpObj->type ?? 1);
3125
            if ($lpType === CLp::SCORM_TYPE) {
3126
                $entries[] = (object)[
3127
                    'source_lp_id' => (int)$srcLpId,
3128
                    'lp_id_dest'   => (int)($lpObj->destination_id ?? 0),
3129
                ];
3130
            }
3131
        }
3132
3133
        error_log($logp.'entries='.count($entries));
3134
        if (empty($entries)) { return; }
3135
3136
        $lpTop = $docRepo->ensureLearningPathSystemFolder($courseEntity, $session);
3137
3138
        foreach ($entries as $sc) {
3139
            // Locate package (zip or folder → temp zip)
3140
            $srcLpId = (int)($sc->source_lp_id ?? 0);
3141
            $pkg = $this->findScormPackageForLp($srcLpId);
3142
            if (empty($pkg['zip'])) {
3143
                error_log($logp.'No package (zip/folder) found for a SCORM entry');
3144
                continue;
3145
            }
3146
            $zipAbs  = $pkg['zip'];
3147
            $zipTemp = (bool)$pkg['temp'];
3148
3149
            // Map LP title/dest for folder name
3150
            $lpId = 0; $lpTitle = 'Untitled';
3151
            if (!empty($sc->lp_id_dest)) {
3152
                $lpId = (int) $sc->lp_id_dest;
3153
            } elseif ($srcLpId && !empty($lpBucket[$srcLpId]->destination_id)) {
3154
                $lpId = (int) $lpBucket[$srcLpId]->destination_id;
3155
            }
3156
            $lpEntity = $lpId ? Container::getLpRepository()->find($lpId) : null;
3157
            if ($lpEntity) { $lpTitle = $lpEntity->getTitle() ?: $lpTitle; }
3158
3159
            $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string)$lpTitle))) ?: 'Untitled';
3160
            $folderTitleBase = sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle);
3161
            $folderTitle     = $folderTitleBase;
3162
3163
            $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3164
            if ($exists) {
3165
                if ($this->file_option === FILE_SKIP) {
3166
                    error_log($logp."Skip due to folder name collision: '$folderTitle'");
3167
                    if ($zipTemp) { @unlink($zipAbs); }
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

3167
                    if ($zipTemp) { /** @scrutinizer ignore-unhandled */ @unlink($zipAbs); }

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
3168
                    continue;
3169
                }
3170
                if ($this->file_option === FILE_RENAME) {
3171
                    $i = 1;
3172
                    do {
3173
                        $folderTitle = $folderTitleBase.' ('.$i.')';
3174
                        $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle);
3175
                        $i++;
3176
                    } while ($exists);
3177
                }
3178
                if ($this->file_option === FILE_OVERWRITE && $lpEntity) {
3179
                    $docRepo->purgeScormZip($courseEntity, $lpEntity);
3180
                    $em->flush();
3181
                }
3182
            }
3183
3184
            // Upload ZIP under Documents
3185
            $uploaded = new UploadedFile(
3186
                $zipAbs, basename($zipAbs), 'application/zip', null, true
3187
            );
3188
            $lpFolder = $docRepo->ensureFolder(
3189
                $courseEntity, $lpTop, $folderTitle,
3190
                ResourceLink::VISIBILITY_DRAFT, $session
3191
            );
3192
            $docRepo->createFileInFolder(
3193
                $courseEntity, $lpFolder, $uploaded,
3194
                sprintf('SCORM ZIP for LP #%d', $lpId),
3195
                ResourceLink::VISIBILITY_DRAFT, $session
3196
            );
3197
            $em->flush();
3198
3199
            if ($zipTemp) { @unlink($zipAbs); }
3200
            error_log($logp."ZIP stored under folder '$folderTitle'");
3201
        }
3202
    }
3203
3204
    /**
3205
     * Restore learnpaths (SCORM-aware).
3206
     * For SCORM LPs, it accepts a real zip or zips a folder-on-the-fly if needed.
3207
     * This version adds strict checks, robust logging and a guaranteed fallback LP.
3208
     */
3209
    public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = '')
3210
    {
3211
        $logp = 'RESTORE_LP: ';
3212
3213
        // --- REQUIRED INITIALIZATION (avoid "Undefined variable $courseEntity") ---
3214
        $courseInfo = $this->destination_course_info ?? [];
3215
        $courseId   = (int)($courseInfo['real_id'] ?? 0);
3216
        if ($courseId <= 0) {
3217
            error_log($logp.'Missing destination course id; aborting.');
3218
            return;
3219
        }
3220
3221
        $courseEntity = api_get_course_entity($courseId);
3222
        if (!$courseEntity) {
3223
            error_log($logp.'api_get_course_entity() returned null for id='.$courseId.'; aborting.');
3224
            return;
3225
        }
3226
3227
        // Session entity is optional
3228
        $session = $session_id ? api_get_session_entity((int)$session_id) : null;
3229
3230
        $em     = Database::getManager();
3231
        $lpRepo = Container::getLpRepository();
3232
3233
        /**
3234
         * Resolve a resource "bucket" by type (constant or string) and return [key, data].
3235
         * - Normalizes common aliases (case-insensitive).
3236
         * - Keeps original bucket key so we can write back destination_id on the right slot.
3237
         */
3238
        $getBucketWithKey = function (int|string $type) use ($logp) {
3239
            // Map constants to canonical strings
3240
            if (is_int($type)) {
3241
                $type = match ($type) {
3242
                    defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : -1 => 'learnpath',
3243
                    defined('RESOURCE_SCORM')     ? RESOURCE_SCORM     : -2 => 'scorm',
3244
                    default => (string)$type,
3245
                };
3246
            }
3247
3248
            // Common legacy aliases
3249
            $aliases = [
3250
                'learnpath' => ['learnpath','coursecopylearnpath','CourseCopyLearnpath','learning_path'],
3251
                'scorm'     => ['scorm','scormdocument','ScormDocument'],
3252
            ];
3253
3254
            $want = strtolower((string)$type);
3255
            $wantedKeys = array_unique(array_merge([$type], $aliases[$want] ?? []));
3256
3257
            $res = is_array($this->course->resources ?? null) ? $this->course->resources : [];
3258
            if (empty($res)) {
3259
                error_log($logp."resources array is empty or invalid");
3260
                return [null, []];
3261
            }
3262
3263
            // 1) Exact match
3264
            foreach ($wantedKeys as $k) {
3265
                if (isset($res[$k]) && is_array($res[$k])) {
3266
                    error_log($logp."bucket '". $type ."' found as '$k' (".count($res[$k]).")");
3267
                    return [$k, $res[$k]];
3268
                }
3269
            }
3270
            // 2) Case-insensitive match
3271
            $lowerWanted = array_map('strtolower', $wantedKeys);
3272
            foreach ($res as $k => $v) {
3273
                if (is_string($k) && in_array(strtolower($k), $lowerWanted, true) && is_array($v)) {
3274
                    error_log($logp."bucket '". $type ."' found as '$k' (".count($v).")");
3275
                    return [$k, $v];
3276
                }
3277
            }
3278
3279
            error_log($logp."bucket '".(string)$type."' not found");
3280
            return [null, []];
3281
        };
3282
3283
        // Resolve learnpath bucket (returning its actual key to write back destination_id)
3284
        [$lpBucketKey, $lpBucket] = $getBucketWithKey(defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath');
3285
        if (empty($lpBucket)) {
3286
            error_log($logp."No LPs to process");
3287
            return;
3288
        }
3289
3290
        // Optional: resolve scorm bucket (may be used by other helpers)
3291
        [$_scormKey, $scormBucket] = $getBucketWithKey(defined('RESOURCE_SCORM') ? RESOURCE_SCORM : 'scorm');
3292
        error_log($logp."LPs=".count($lpBucket).", SCORM entries=".count($scormBucket));
3293
3294
        foreach ($lpBucket as $srcLpId => $lpObj) {
3295
            $lpName   = $lpObj->name ?? ($lpObj->title ?? ('LP '.$srcLpId));
3296
            $lpType   = (int)($lpObj->lp_type ?? $lpObj->type ?? 1); // 2 = SCORM
3297
            $encoding = $lpObj->default_encoding ?? 'UTF-8';
3298
3299
            error_log($logp."LP src=$srcLpId, name='". $lpName ."', type=".$lpType);
3300
3301
            // ---- SCORM ----
3302
            if ($lpType === CLp::SCORM_TYPE) {
3303
                $createdLpId = 0;
3304
                $zipAbs  = null;
3305
                $zipTemp = false;
3306
3307
                try {
3308
                    // Find a real SCORM ZIP (or zip a folder on-the-fly)
3309
                    $pkg    = $this->findScormPackageForLp((int)$srcLpId);
3310
                    $zipAbs = $pkg['zip'] ?? null;
3311
                    $zipTemp = !empty($pkg['temp']);
3312
3313
                    if (!$zipAbs || !is_readable($zipAbs)) {
3314
                        error_log($logp."SCORM LP src=$srcLpId: NO ZIP found/readable");
3315
                    } else {
3316
                        error_log($logp."SCORM LP src=$srcLpId ZIP=".$zipAbs);
3317
3318
                        // Try to resolve currentDir from the BACKUP (folder or ZIP)
3319
                        $currentDir    = '';
3320
                        $tmpExtractDir = '';
3321
                        $bp = (string) ($this->course->backup_path ?? '');
3322
3323
                        // Case A: backup_path is an extracted directory
3324
                        if ($bp && is_dir($bp)) {
3325
                            try {
3326
                                $rii = new \RecursiveIteratorIterator(
3327
                                    new \RecursiveDirectoryIterator($bp, \FilesystemIterator::SKIP_DOTS),
3328
                                    \RecursiveIteratorIterator::SELF_FIRST
3329
                                );
3330
                                foreach ($rii as $f) {
3331
                                    if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') {
3332
                                        $currentDir = $f->getPath();
3333
                                        break;
3334
                                    }
3335
                                }
3336
                            } catch (\Throwable $e) {
3337
                                error_log($logp.'Scan BACKUP dir failed: '.$e->getMessage());
3338
                            }
3339
                        }
3340
3341
                        // Case B: backup_path is a ZIP under var/cache/course_backups
3342
                        if (!$currentDir && $bp && is_file($bp) && preg_match('/\.zip$/i', $bp)) {
3343
                            $tmpExtractDir = rtrim(sys_get_temp_dir(), '/').'/scorm_restore_'.uniqid('', true);
3344
                            @mkdir($tmpExtractDir, 0777, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

3344
                            /** @scrutinizer ignore-unhandled */ @mkdir($tmpExtractDir, 0777, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
3345
                            try {
3346
                                $zf = new ZipFile();
3347
                                $zf->openFile($bp);
3348
                                $zf->extractTo($tmpExtractDir);
3349
                                $zf->close();
3350
3351
                                $rii = new \RecursiveIteratorIterator(
3352
                                    new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS),
3353
                                    \RecursiveIteratorIterator::SELF_FIRST
3354
                                );
3355
                                foreach ($rii as $f) {
3356
                                    if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') {
3357
                                        $currentDir = $f->getPath();
3358
                                        break;
3359
                                    }
3360
                                }
3361
                            } catch (\Throwable $e) {
3362
                                error_log($logp.'TMP unzip failed: '.$e->getMessage());
3363
                            }
3364
                        }
3365
3366
                        if ($currentDir) {
3367
                            error_log($logp.'Resolved currentDir from BACKUP: '.$currentDir);
3368
                        } else {
3369
                            error_log($logp.'Could not resolve currentDir from backup; import_package will derive it');
3370
                        }
3371
3372
                        // Import in scorm class (import_manifest will create LP + items)
3373
                        $sc = new \scorm();
3374
                        $fileInfo = ['tmp_name' => $zipAbs, 'name' => basename($zipAbs)];
3375
3376
                        $ok = $sc->import_package($fileInfo, $currentDir);
3377
3378
                        // Cleanup tmp if we extracted the backup ZIP
3379
                        if ($tmpExtractDir && is_dir($tmpExtractDir)) {
3380
                            $it = new \RecursiveIteratorIterator(
3381
                                new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS),
3382
                                \RecursiveIteratorIterator::CHILD_FIRST
3383
                            );
3384
                            foreach ($it as $p) {
3385
                                $p->isDir() ? @rmdir($p->getPathname()) : @unlink($p->getPathname());
3386
                            }
3387
                            @rmdir($tmpExtractDir);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rmdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

3387
                            /** @scrutinizer ignore-unhandled */ @rmdir($tmpExtractDir);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
3388
                        }
3389
3390
                        if ($ok !== true) {
3391
                            error_log($logp."import_package() returned false");
3392
                        } else {
3393
                            if (empty($sc->manifestToString)) {
3394
                                error_log($logp."manifestToString empty after import_package()");
3395
                            } else {
3396
                                // Parse & import manifest (creates LP + items)
3397
                                $sc->parse_manifest();
3398
3399
                                /** @var CLp|null $lp */
3400
                                $lp = $sc->import_manifest($courseId, 1, (int) $session_id);
3401
                                if ($lp instanceof CLp) {
3402
                                    if (property_exists($lpObj, 'content_local')) {
3403
                                        $lp->setContentLocal((int) $lpObj->content_local);
3404
                                    }
3405
                                    if (property_exists($lpObj, 'content_maker')) {
3406
                                        $lp->setContentMaker((string) $lpObj->content_maker);
3407
                                    }
3408
                                    $lp->setDefaultEncoding((string) $encoding);
3409
3410
                                    $em->persist($lp);
3411
                                    $em->flush();
3412
3413
                                    $createdLpId = (int)$lp->getIid();
3414
                                    if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) {
3415
                                        $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId;
3416
                                    }
3417
                                    error_log($logp."SCORM LP created id=".$createdLpId." (via manifest)");
3418
                                } else {
3419
                                    error_log($logp."import_manifest() returned NULL");
3420
                                }
3421
                            }
3422
                        }
3423
                    }
3424
                } catch (\Throwable $e) {
3425
                    error_log($logp.'EXCEPTION: '.$e->getMessage());
3426
                } finally {
3427
                    if (empty($createdLpId)) {
3428
                        $lp = (new CLp())
3429
                            ->setLpType(CLp::SCORM_TYPE)
3430
                            ->setTitle((string) $lpName)
3431
                            ->setDefaultEncoding((string) $encoding)
3432
                            ->setJsLib('scorm_api.php')
3433
                            ->setUseMaxScore(1)
3434
                            ->setParent($courseEntity);
3435
3436
                        if (method_exists($lp, 'addCourseLink')) {
3437
                            // pass session only if available
3438
                            $lp->addCourseLink($courseEntity, $session ?: null);
3439
                        }
3440
3441
                        $lpRepo->createLp($lp);
3442
                        $em->flush();
3443
3444
                        $createdLpId = (int) $lp->getIid();
3445
                        if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) {
3446
                            $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId;
3447
                        }
3448
                        error_log($logp."SCORM LP created id=".$createdLpId." (FALLBACK)");
3449
                    }
3450
3451
                    // Remove temp ZIP if we created it in findScormPackageForLp()
3452
                    if (!empty($zipTemp) && !empty($zipAbs) && is_file($zipAbs)) {
3453
                        @unlink($zipAbs);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

3453
                        /** @scrutinizer ignore-unhandled */ @unlink($zipAbs);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
3454
                    }
3455
                }
3456
3457
                continue; // next LP
3458
            }
3459
3460
            // ---- Non-SCORM ----
3461
            $lp = (new CLp())
3462
                ->setLpType(CLp::LP_TYPE)
3463
                ->setTitle((string) $lpName)
3464
                ->setDefaultEncoding((string) $encoding)
3465
                ->setJsLib('scorm_api.php')
3466
                ->setUseMaxScore(1)
3467
                ->setParent($courseEntity);
3468
3469
            if (method_exists($lp, 'addCourseLink')) {
3470
                $lp->addCourseLink($courseEntity, $session ?: null);
3471
            }
3472
3473
            $lpRepo->createLp($lp);
3474
            $em->flush();
3475
            error_log($logp."Standard LP created id=".$lp->getIid());
3476
3477
            if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) {
3478
                $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = (int) $lp->getIid();
3479
            }
3480
3481
            // Manual items (only for non-SCORM if present in backup)
3482
            if (!empty($lpObj->items) && is_array($lpObj->items)) {
3483
                $lpItemRepo = Container::getLpItemRepository();
3484
                $rootItem   = $lpItemRepo->getRootItem($lp->getIid());
3485
                $parents    = [0 => $rootItem];
3486
3487
                foreach ($lpObj->items as $it) {
3488
                    $level = (int) ($it['level'] ?? 0);
3489
                    if (!isset($parents[$level])) { $parents[$level] = end($parents); }
3490
                    $parentEntity = $parents[$level] ?? $rootItem;
3491
3492
                    $lpItem = (new CLpItem())
3493
                        ->setTitle((string) ($it['title'] ?? ''))
3494
                        ->setItemType((string) ($it['item_type'] ?? 'dir'))
3495
                        ->setRef((string) ($it['identifier'] ?? ''))
3496
                        ->setPath((string) ($it['path'] ?? ''))
3497
                        ->setMinScore(0)
3498
                        ->setMaxScore((int) ($it['max_score'] ?? 100))
3499
                        ->setPrerequisite((string) ($it['prerequisites'] ?? ''))
3500
                        ->setLaunchData((string) ($it['datafromlms'] ?? ''))
3501
                        ->setParameters((string) ($it['parameters'] ?? ''))
3502
                        ->setLp($lp)
3503
                        ->setParent($parentEntity);
3504
3505
                    $lpItemRepo->create($lpItem);
3506
                    $parents[$level+1] = $lpItem;
3507
                }
3508
                $em->flush();
3509
                error_log($logp."Standard LP id=".$lp->getIid()." items=".count($lpObj->items));
3510
            }
3511
        }
3512
    }
3513
3514
    /**
3515
     * Restore glossary.
3516
     */
3517
    public function restore_glossary($sessionId = 0)
3518
    {
3519
        if (!$this->course->has_resources(RESOURCE_GLOSSARY)) {
3520
            $this->debug && error_log('COURSE_DEBUG: restore_glossary: no glossary resources in backup.');
3521
            return;
3522
        }
3523
3524
        $em            = Database::getManager();
3525
        /** @var CGlossaryRepository $repo */
3526
        $repo          = $em->getRepository(CGlossary::class);
3527
        /** @var CourseEntity $courseEntity */
3528
        $courseEntity  = api_get_course_entity($this->destination_course_id);
3529
        /** @var SessionEntity|null $sessionEntity */
3530
        $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null;
3531
3532
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
3533
        if ($backupRoot === '') {
3534
            $this->debug && error_log('COURSE_DEBUG: restore_glossary: backupRoot empty; URL rewriting may be partial.');
3535
        }
3536
3537
        $resources = $this->course->resources;
3538
3539
        foreach ($resources[RESOURCE_GLOSSARY] as $legacyId => $gls) {
3540
            try {
3541
                $title = (string) ($gls->name ?? $gls->title ?? '');
3542
                $desc  = (string) ($gls->description ?? '');
3543
                $order = (int)  ($gls->display_order ?? 0);
3544
3545
                $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets($desc, $courseEntity, $backupRoot) ?? $desc;
3546
3547
                $existing = null;
3548
                if (method_exists($repo, 'getResourcesByCourse')) {
3549
                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
3550
                        ->andWhere('resource.title = :title')
3551
                        ->setParameter('title', $title)
3552
                        ->setMaxResults(1);
3553
                    $existing = $qb->getQuery()->getOneOrNullResult();
3554
                } else {
3555
                    $existing = $repo->findOneBy(['title' => $title]);
3556
                }
3557
3558
                if ($existing instanceof CGlossary) {
3559
                    switch ($this->file_option) {
3560
                        case FILE_SKIP:
3561
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid();
3562
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: term exists title='{$title}' (skip).");
3563
                            continue 2;
3564
3565
                        case FILE_RENAME:
3566
                            $base = $title === '' ? 'Glossary term' : $title;
3567
                            $try  = $base;
3568
                            $i    = 1;
3569
                            $isTaken = static function($repo, $courseEntity, $sessionEntity, $titleTry) {
3570
                                if (method_exists($repo, 'getResourcesByCourse')) {
3571
                                    $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity)
3572
                                        ->andWhere('resource.title = :t')->setParameter('t', $titleTry)
3573
                                        ->setMaxResults(1);
3574
                                    return (bool)$qb->getQuery()->getOneOrNullResult();
3575
                                }
3576
                                return (bool)$repo->findOneBy(['title' => $titleTry]);
3577
                            };
3578
                            while ($isTaken($repo, $courseEntity, $sessionEntity, $try)) {
3579
                                $try = $base.' ('.($i++).')';
3580
                            }
3581
                            $title = $try;
3582
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: renaming to '{$title}'.");
3583
                            break;
3584
3585
                        case FILE_OVERWRITE:
3586
                            $em->remove($existing);
3587
                            $em->flush();
3588
                            $this->debug && error_log("COURSE_DEBUG: restore_glossary: existing term deleted (overwrite).");
3589
                            break;
3590
3591
                        default:
3592
                            $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid();
3593
                            continue 2;
3594
                    }
3595
                }
3596
3597
                $entity = new CGlossary();
3598
                $entity
3599
                    ->setTitle($title)
3600
                    ->setDescription($desc);
3601
3602
                if (method_exists($entity, 'setParent')) {
3603
                    $entity->setParent($courseEntity);
3604
                }
3605
3606
                if (method_exists($entity, 'addCourseLink')) {
3607
                    $entity->addCourseLink($courseEntity, $sessionEntity);
3608
                }
3609
3610
                if (method_exists($repo, 'create')) {
3611
                    $repo->create($entity);
3612
                } else {
3613
                    $em->persist($entity);
3614
                    $em->flush();
3615
                }
3616
3617
                if ($order && method_exists($entity, 'setDisplayOrder')) {
3618
                    $entity->setDisplayOrder($order);
3619
                    $em->flush();
3620
                }
3621
3622
                $newId = (int)$entity->getIid();
3623
                if (!isset($this->course->resources[RESOURCE_GLOSSARY][$legacyId])) {
3624
                    $this->course->resources[RESOURCE_GLOSSARY][$legacyId] = new \stdClass();
3625
                }
3626
                $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = $newId;
3627
3628
                $this->debug && error_log("COURSE_DEBUG: restore_glossary: created term iid={$newId}, title='{$title}'");
3629
            } catch (\Throwable $e) {
3630
                error_log('COURSE_DEBUG: restore_glossary: failed: '.$e->getMessage());
3631
                continue;
3632
            }
3633
        }
3634
    }
3635
3636
    /**
3637
     * @param int $sessionId
3638
     */
3639
    public function restore_wiki($sessionId = 0)
3640
    {
3641
        if (!$this->course->has_resources(RESOURCE_WIKI)) {
3642
            $this->debug && error_log('COURSE_DEBUG: restore_wiki: no wiki resources in backup.');
3643
            return;
3644
        }
3645
3646
        $em            = Database::getManager();
3647
        /** @var CWikiRepository $repo */
3648
        $repo          = $em->getRepository(CWiki::class);
3649
        /** @var CourseEntity $courseEntity */
3650
        $courseEntity  = api_get_course_entity($this->destination_course_id);
3651
        /** @var SessionEntity|null $sessionEntity */
3652
        $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null;
3653
3654
        $cid = (int)$this->destination_course_id;
3655
        $sid = (int)($sessionEntity?->getId() ?? 0);
3656
3657
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
3658
        if ($backupRoot === '') {
3659
            $this->debug && error_log('COURSE_DEBUG: restore_wiki: backupRoot empty; URL rewriting may be partial.');
3660
        }
3661
3662
        $resources = $this->course->resources;
3663
3664
        foreach ($resources[RESOURCE_WIKI] as $legacyId => $w) {
3665
            try {
3666
                $rawTitle  = (string)($w->title ?? $w->name ?? '');
3667
                $reflink   = (string)($w->reflink ?? '');
3668
                $content   = (string)($w->content ?? '');
3669
                $comment   = (string)($w->comment ?? '');
3670
                $progress  = (string)($w->progress ?? '');
3671
                $version   = (int)  ($w->version ?? 1);
3672
                $groupId   = (int)  ($w->group_id ?? 0);
3673
                $userId    = (int)  ($w->user_id  ?? api_get_user_id());
3674
                $dtimeStr  = (string)($w->dtime ?? '');
3675
                $dtime     = null;
3676
                try { $dtime = $dtimeStr !== '' ? new \DateTime($dtimeStr) : new \DateTime('now', new \DateTimeZone('UTC')); }
3677
                catch (\Throwable) { $dtime = new \DateTime('now', new \DateTimeZone('UTC')); }
3678
3679
                $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
3680
                    $content,
3681
                    $courseEntity,
3682
                    $backupRoot
3683
                ) ?? $content;
3684
3685
                if ($rawTitle === '') {
3686
                    $rawTitle = 'Wiki page';
3687
                }
3688
                if ($content === '') {
3689
                    $content = '<p>&nbsp;</p>';
3690
                }
3691
3692
                $makeSlug = static function (string $s): string {
3693
                    $s = strtolower(trim($s));
3694
                    $s = preg_replace('/[^\p{L}\p{N}]+/u', '-', $s) ?: '';
3695
                    $s = trim($s, '-');
3696
                    return $s === '' ? 'page' : $s;
3697
                };
3698
                $reflink = $reflink !== '' ? $makeSlug($reflink) : $makeSlug($rawTitle);
3699
3700
                $qbExists = $repo->createQueryBuilder('w')
3701
                    ->select('w.iid')
3702
                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
3703
                    ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
3704
                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId);
3705
                if ($sid > 0) {
3706
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
3707
                } else {
3708
                    $qbExists->andWhere('COALESCE(w.sessionId,0) = 0');
3709
                }
3710
                $exists = (bool)$qbExists->getQuery()->getOneOrNullResult();
3711
3712
                if ($exists) {
3713
                    switch ($this->file_option) {
3714
                        case FILE_SKIP:
3715
                            $qbLast = $repo->createQueryBuilder('w')
3716
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
3717
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
3718
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId)
3719
                                ->orderBy('w.version', 'DESC')->setMaxResults(1);
3720
                            if ($sid > 0) { $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); }
3721
                            else          { $qbLast->andWhere('COALESCE(w.sessionId,0) = 0'); }
3722
3723
                            /** @var CWiki|null $last */
3724
                            $last = $qbLast->getQuery()->getOneOrNullResult();
3725
                            $dest = $last ? (int)($last->getPageId() ?: $last->getIid()) : 0;
3726
                            $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = $dest;
3727
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: reflink '{$reflink}' exists → skip (page_id={$dest}).");
3728
                            continue 2;
3729
3730
                        case FILE_RENAME:
3731
                            $baseSlug = $reflink;
3732
                            $baseTitle = $rawTitle;
3733
                            $i = 1;
3734
                            $trySlug = $baseSlug.'-'.$i;
3735
                            $isTaken = function (string $slug) use ($repo, $cid, $sid, $groupId): bool {
3736
                                $qb = $repo->createQueryBuilder('w')
3737
                                    ->select('w.iid')
3738
                                    ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
3739
                                    ->andWhere('w.reflink = :r')->setParameter('r', $slug)
3740
                                    ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId);
3741
                                if ($sid > 0) $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
3742
                                else          $qb->andWhere('COALESCE(w.sessionId,0) = 0');
3743
                                $qb->setMaxResults(1);
3744
                                return (bool)$qb->getQuery()->getOneOrNullResult();
3745
                            };
3746
                            while ($isTaken($trySlug)) { $trySlug = $baseSlug.'-'.(++$i); }
3747
                            $reflink  = $trySlug;
3748
                            $rawTitle = $baseTitle.' ('.$i.')';
3749
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed reflink to '{$reflink}' / title='{$rawTitle}'.");
3750
                            break;
3751
3752
                        case FILE_OVERWRITE:
3753
                            $qbAll = $repo->createQueryBuilder('w')
3754
                                ->andWhere('w.cId = :cid')->setParameter('cid', $cid)
3755
                                ->andWhere('w.reflink = :r')->setParameter('r', $reflink)
3756
                                ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId);
3757
                            if ($sid > 0) $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid);
3758
                            else          $qbAll->andWhere('COALESCE(w.sessionId,0) = 0');
3759
3760
                            foreach ($qbAll->getQuery()->getResult() as $old) {
3761
                                $em->remove($old);
3762
                            }
3763
                            $em->flush();
3764
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: removed previous pages for reflink '{$reflink}' (overwrite).");
3765
                            break;
3766
3767
                        default:
3768
                            $this->debug && error_log("COURSE_DEBUG: restore_wiki: unknown file_option → skip.");
3769
                            continue 2;
3770
                    }
3771
                }
3772
3773
                $wiki = new CWiki();
3774
                $wiki->setCId($cid);
3775
                $wiki->setSessionId($sid);
3776
                $wiki->setGroupId($groupId);
3777
                $wiki->setReflink($reflink);
3778
                $wiki->setTitle($rawTitle);
3779
                $wiki->setContent($content);
3780
                $wiki->setComment($comment);
3781
                $wiki->setProgress($progress);
3782
                $wiki->setVersion($version > 0 ? $version : 1);
3783
                $wiki->setUserId($userId);
3784
                $wiki->setDtime($dtime);
3785
                $wiki->setIsEditing(0);
3786
                $wiki->setTimeEdit(null);
3787
                $wiki->setHits((int) ($w->hits ?? 0));
3788
                $wiki->setAddlock((int) ($w->addlock ?? 1));
3789
                $wiki->setEditlock((int) ($w->editlock ?? 0));
3790
                $wiki->setVisibility((int) ($w->visibility ?? 1));
3791
                $wiki->setAddlockDisc((int) ($w->addlock_disc ?? 1));
3792
                $wiki->setVisibilityDisc((int) ($w->visibility_disc ?? 1));
3793
                $wiki->setRatinglockDisc((int) ($w->ratinglock_disc ?? 1));
3794
                $wiki->setAssignment((int) ($w->assignment ?? 0));
3795
                $wiki->setScore(isset($w->score) ? (int) $w->score : 0);
3796
                $wiki->setLinksto((string) ($w->linksto ?? ''));
3797
                $wiki->setTag((string) ($w->tag ?? ''));
3798
                $wiki->setUserIp((string) ($w->user_ip ?? api_get_real_ip()));
3799
3800
                if (method_exists($wiki, 'setParent')) {
3801
                    $wiki->setParent($courseEntity);
3802
                }
3803
                if (method_exists($wiki, 'setCreator')) {
3804
                    $wiki->setCreator(api_get_user_entity());
3805
                }
3806
                $groupEntity = $groupId ? api_get_group_entity($groupId) : null;
3807
                if (method_exists($wiki, 'addCourseLink')) {
3808
                    $wiki->addCourseLink($courseEntity, $sessionEntity, $groupEntity);
3809
                }
3810
3811
                $em->persist($wiki);
3812
                $em->flush();
3813
3814
                if (empty($w->page_id)) {
3815
                    $wiki->setPageId((int) $wiki->getIid());
3816
                    $em->flush();
3817
                } else {
3818
                    $pid = (int) $w->page_id;
3819
                    $wiki->setPageId($pid > 0 ? $pid : (int) $wiki->getIid());
3820
                    $em->flush();
3821
                }
3822
3823
                $conf = new CWikiConf();
3824
                $conf->setCId($cid);
3825
                $conf->setPageId((int) $wiki->getPageId());
3826
                $conf->setTask((string) ($w->task ?? ''));
3827
                $conf->setFeedback1((string) ($w->feedback1 ?? ''));
3828
                $conf->setFeedback2((string) ($w->feedback2 ?? ''));
3829
                $conf->setFeedback3((string) ($w->feedback3 ?? ''));
3830
                $conf->setFprogress1((string) ($w->fprogress1 ?? ''));
3831
                $conf->setFprogress2((string) ($w->fprogress2 ?? ''));
3832
                $conf->setFprogress3((string) ($w->fprogress3 ?? ''));
3833
                $conf->setMaxText(isset($w->max_text) ? (int) $w->max_text : 0);
3834
                $conf->setMaxVersion(isset($w->max_version) ? (int) $w->max_version : 0);
3835
                try {
3836
                    $conf->setStartdateAssig(!empty($w->startdate_assig) ? new \DateTime((string) $w->startdate_assig) : null);
3837
                } catch (\Throwable) { $conf->setStartdateAssig(null); }
3838
                try {
3839
                    $conf->setEnddateAssig(!empty($w->enddate_assig) ? new \DateTime((string) $w->enddate_assig) : null);
3840
                } catch (\Throwable) { $conf->setEnddateAssig(null); }
3841
                $conf->setDelayedsubmit(isset($w->delayedsubmit) ? (int) $w->delayedsubmit : 0);
3842
3843
                $em->persist($conf);
3844
                $em->flush();
3845
3846
                $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = (int) $wiki->getPageId();
3847
3848
                $this->debug && error_log("COURSE_DEBUG: restore_wiki: created page iid=".(int) $wiki->getIid()." page_id=".(int) $wiki->getPageId()." reflink='{$reflink}'");
3849
            } catch (\Throwable $e) {
3850
                error_log('COURSE_DEBUG: restore_wiki: failed: '.$e->getMessage());
3851
                continue;
3852
            }
3853
        }
3854
    }
3855
3856
    /**
3857
     * Restore Thematics.
3858
     *
3859
     * @param int $sessionId
3860
     */
3861
    public function restore_thematic($sessionId = 0)
3862
    {
3863
        if (!$this->course->has_resources(RESOURCE_THEMATIC)) {
3864
            $this->debug && error_log('COURSE_DEBUG: restore_thematic: no thematic resources.');
3865
            return;
3866
        }
3867
3868
        $em            = Database::getManager();
3869
        /** @var CourseEntity $courseEntity */
3870
        $courseEntity  = api_get_course_entity($this->destination_course_id);
3871
        /** @var SessionEntity|null $sessionEntity */
3872
        $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null;
3873
3874
        $cid = (int)$this->destination_course_id;
3875
        $sid = (int)($sessionEntity?->getId() ?? 0);
3876
3877
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
3878
3879
        $resources = $this->course->resources;
3880
3881
        foreach ($resources[RESOURCE_THEMATIC] as $legacyId => $t) {
3882
            try {
3883
                $p = (array)($t->params ?? []);
3884
                $title   = trim((string)($p['title']   ?? $p['name'] ?? ''));
3885
                $content = (string)($p['content'] ?? '');
3886
                $active  = (bool)  ($p['active']  ?? true);
3887
3888
                if ($content !== '') {
3889
                    $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
3890
                        $content,
3891
                        $courseEntity,
3892
                        $backupRoot
3893
                    ) ?? $content;
3894
                }
3895
3896
                if ($title === '') {
3897
                    $title = 'Thematic';
3898
                }
3899
3900
                $thematic = new CThematic();
3901
                $thematic
3902
                    ->setTitle($title)
3903
                    ->setContent($content)
3904
                    ->setActive($active);
3905
3906
                if (method_exists($thematic, 'setParent')) {
3907
                    $thematic->setParent($courseEntity);
3908
                }
3909
                if (method_exists($thematic, 'setCreator')) {
3910
                    $thematic->setCreator(api_get_user_entity());
3911
                }
3912
                if (method_exists($thematic, 'addCourseLink')) {
3913
                    $thematic->addCourseLink($courseEntity, $sessionEntity);
3914
                }
3915
3916
                $em->persist($thematic);
3917
                $em->flush();
3918
3919
                $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int)$thematic->getIid();
3920
3921
                $advList = (array)($t->thematic_advance_list ?? []);
3922
                foreach ($advList as $adv) {
3923
                    if (!is_array($adv)) { $adv = (array)$adv; }
3924
3925
                    $advContent = (string)($adv['content'] ?? '');
3926
                    if ($advContent !== '') {
3927
                        $advContent = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
3928
                            $advContent,
3929
                            $courseEntity,
3930
                            $backupRoot
3931
                        ) ?? $advContent;
3932
                    }
3933
3934
                    $startStr = (string)($adv['start_date'] ?? $adv['startDate'] ?? '');
3935
                    try {
3936
                        $startDate = $startStr !== '' ? new \DateTime($startStr) : new \DateTime('now', new \DateTimeZone('UTC'));
3937
                    } catch (\Throwable) {
3938
                        $startDate = new \DateTime('now', new \DateTimeZone('UTC'));
3939
                    }
3940
3941
                    $duration    = (int)($adv['duration'] ?? 1);
3942
                    $doneAdvance = (bool)($adv['done_advance'] ?? $adv['doneAdvance'] ?? false);
3943
3944
                    $advance = new CThematicAdvance();
3945
                    $advance
3946
                        ->setThematic($thematic)
3947
                        ->setContent($advContent)
3948
                        ->setStartDate($startDate)
3949
                        ->setDuration($duration)
3950
                        ->setDoneAdvance($doneAdvance);
3951
3952
                    $attId = (int)($adv['attendance_id'] ?? 0);
3953
                    if ($attId > 0) {
3954
                        $att = $em->getRepository(CAttendance::class)->find($attId);
3955
                        if ($att) {
3956
                            $advance->setAttendance($att);
3957
                        }
3958
                    }
3959
3960
                    $roomId = (int)($adv['room_id'] ?? 0);
3961
                    if ($roomId > 0) {
3962
                        $room = $em->getRepository(Room::class)->find($roomId);
3963
                        if ($room) {
3964
                            $advance->setRoom($room);
3965
                        }
3966
                    }
3967
3968
                    $em->persist($advance);
3969
                }
3970
3971
                $planList = (array)($t->thematic_plan_list ?? []);
3972
                foreach ($planList as $pl) {
3973
                    if (!is_array($pl)) { $pl = (array)$pl; }
3974
3975
                    $plTitle = trim((string)($pl['title'] ?? ''));
3976
                    if ($plTitle === '') { $plTitle = 'Plan'; }
3977
3978
                    $plDesc  = (string)($pl['description'] ?? '');
3979
                    if ($plDesc !== '') {
3980
                        $plDesc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
3981
                            $plDesc,
3982
                            $courseEntity,
3983
                            $backupRoot
3984
                        ) ?? $plDesc;
3985
                    }
3986
3987
                    $descType = (int)($pl['description_type'] ?? $pl['descriptionType'] ?? 0);
3988
3989
                    $plan = new CThematicPlan();
3990
                    $plan
3991
                        ->setThematic($thematic)
3992
                        ->setTitle($plTitle)
3993
                        ->setDescription($plDesc)
3994
                        ->setDescriptionType($descType);
3995
3996
                    $em->persist($plan);
3997
                }
3998
3999
                $em->flush();
4000
4001
                $this->debug && error_log("COURSE_DEBUG: restore_thematic: created thematic iid=".(int)$thematic->getIid()." (advances=".count($advList).", plans=".count($planList).")");
4002
            } catch (\Throwable $e) {
4003
                error_log('COURSE_DEBUG: restore_thematic: failed: '.$e->getMessage());
4004
                continue;
4005
            }
4006
        }
4007
    }
4008
4009
    /**
4010
     * Restore Attendance.
4011
     *
4012
     * @param int $sessionId
4013
     */
4014
    public function restore_attendance($sessionId = 0)
4015
    {
4016
        if (!$this->course->has_resources(RESOURCE_ATTENDANCE)) {
4017
            $this->debug && error_log('COURSE_DEBUG: restore_attendance: no attendance resources.');
4018
            return;
4019
        }
4020
4021
        $em            = Database::getManager();
4022
        /** @var CourseEntity $courseEntity */
4023
        $courseEntity  = api_get_course_entity($this->destination_course_id);
4024
        /** @var SessionEntity|null $sessionEntity */
4025
        $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null;
4026
4027
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
4028
4029
        $resources = $this->course->resources;
4030
4031
        foreach ($resources[RESOURCE_ATTENDANCE] as $legacyId => $att) {
4032
            try {
4033
                $p = (array)($att->params ?? []);
4034
4035
                $title  = trim((string)($p['title'] ?? 'Attendance'));
4036
                $desc   = (string)($p['description'] ?? '');
4037
                $active = (int)($p['active'] ?? 1);
4038
4039
                if ($desc !== '') {
4040
                    $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
4041
                        $desc,
4042
                        $courseEntity,
4043
                        $backupRoot
4044
                    ) ?? $desc;
4045
                }
4046
4047
                $qualTitle  = isset($p['attendance_qualify_title']) ? (string)$p['attendance_qualify_title'] : null;
4048
                $qualMax    = (int)($p['attendance_qualify_max'] ?? 0);
4049
                $weight     = (float)($p['attendance_weight'] ?? 0.0);
4050
                $locked     = (int)($p['locked'] ?? 0);
4051
4052
                $a = new CAttendance();
4053
                $a->setTitle($title)
4054
                    ->setDescription($desc)
4055
                    ->setActive($active)
4056
                    ->setAttendanceQualifyTitle($qualTitle ?? '')
4057
                    ->setAttendanceQualifyMax($qualMax)
4058
                    ->setAttendanceWeight($weight)
4059
                    ->setLocked($locked);
4060
4061
                if (method_exists($a, 'setParent')) {
4062
                    $a->setParent($courseEntity);
4063
                }
4064
                if (method_exists($a, 'setCreator')) {
4065
                    $a->setCreator(api_get_user_entity());
4066
                }
4067
                if (method_exists($a, 'addCourseLink')) {
4068
                    $a->addCourseLink($courseEntity, $sessionEntity);
4069
                }
4070
4071
                $em->persist($a);
4072
                $em->flush();
4073
4074
                $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int)$a->getIid();
4075
4076
                $calList = (array)($att->attendance_calendar ?? []);
4077
                foreach ($calList as $c) {
4078
                    if (!is_array($c)) { $c = (array)$c; }
4079
4080
                    $rawDt = (string)($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? '');
4081
                    try {
4082
                        $dt = $rawDt !== '' ? new \DateTime($rawDt) : new \DateTime('now', new \DateTimeZone('UTC'));
4083
                    } catch (\Throwable) {
4084
                        $dt = new \DateTime('now', new \DateTimeZone('UTC'));
4085
                    }
4086
4087
                    $done     = (bool)($c['done_attendance'] ?? $c['doneAttendance'] ?? false);
4088
                    $blocked  = (bool)($c['blocked'] ?? false);
4089
                    $duration = isset($c['duration']) ? (int)$c['duration'] : null;
4090
4091
                    $cal = new CAttendanceCalendar();
4092
                    $cal->setAttendance($a)
4093
                        ->setDateTime($dt)
4094
                        ->setDoneAttendance($done)
4095
                        ->setBlocked($blocked)
4096
                        ->setDuration($duration);
4097
4098
                    $em->persist($cal);
4099
                    $em->flush();
4100
4101
                    $groupId = (int)($c['group_id'] ?? 0);
4102
                    if ($groupId > 0) {
4103
                        try {
4104
                            $repo = $em->getRepository(CAttendanceCalendarRelGroup::class);
4105
                            if (method_exists($repo, 'addGroupToCalendar')) {
4106
                                $repo->addGroupToCalendar((int)$cal->getIid(), $groupId);
4107
                            }
4108
                        } catch (\Throwable $e) {
4109
                            $this->debug && error_log('COURSE_DEBUG: restore_attendance: calendar group link skipped: '.$e->getMessage());
4110
                        }
4111
                    }
4112
                }
4113
4114
                $em->flush();
4115
                $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int)$a->getIid().' (cal='.count($calList).')');
4116
4117
            } catch (\Throwable $e) {
4118
                error_log('COURSE_DEBUG: restore_attendance: failed: '.$e->getMessage());
4119
                continue;
4120
            }
4121
        }
4122
    }
4123
4124
    /**
4125
     * Restore Works.
4126
     *
4127
     * @param int $sessionId
4128
     */
4129
    public function restore_works(int $sessionId = 0): void
4130
    {
4131
        if (!$this->course->has_resources(RESOURCE_WORK)) {
4132
            return;
4133
        }
4134
4135
        $em            = Database::getManager();
4136
        /** @var CourseEntity $courseEntity */
4137
        $courseEntity  = api_get_course_entity($this->destination_course_id);
4138
        /** @var SessionEntity|null $sessionEntity */
4139
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
4140
4141
        $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : '';
4142
4143
        /** @var CStudentPublicationRepository $pubRepo */
4144
        $pubRepo = Container::getStudentPublicationRepository();
4145
4146
        foreach ($this->course->resources[RESOURCE_WORK] as $legacyId => $obj) {
4147
            try {
4148
                $p = (array)($obj->params ?? []);
4149
4150
                $title = trim((string)($p['title'] ?? 'Work'));
4151
                if ($title === '') { $title = 'Work'; }
4152
4153
                $description = (string)($p['description'] ?? '');
4154
                if ($description !== '') {
4155
                    $description = ChamiloHelper::rewriteLegacyCourseUrlsToAssets(
4156
                        $description,
4157
                        $courseEntity,
4158
                        $backupRoot
4159
                    ) ?? $description;
4160
                }
4161
4162
                $enableQualification = (bool)($p['enable_qualification'] ?? false);
4163
                $addToCalendar       = (int)($p['add_to_calendar'] ?? 0) === 1;
4164
                $expiresOn           = !empty($p['expires_on']) ? new \DateTime($p['expires_on']) : null;
4165
                $endsOn              = !empty($p['ends_on'])    ? new \DateTime($p['ends_on'])    : null;
4166
4167
                $weight              = isset($p['weight']) ? (float)$p['weight'] : 0.0;
4168
                $qualification       = isset($p['qualification']) ? (float)$p['qualification'] : 0.0;
4169
                $allowText           = (int)($p['allow_text_assignment'] ?? 0);
4170
                $defaultVisibility   = (bool)($p['default_visibility'] ?? 0);
4171
                $studentMayDelete    = (bool)($p['student_delete_own_publication'] ?? 0);
4172
                $extensions          = isset($p['extensions']) ? (string)$p['extensions'] : null;
4173
                $groupCategoryWorkId = (int)($p['group_category_work_id'] ?? 0);
4174
                $postGroupId         = (int)($p['post_group_id'] ?? 0);
4175
4176
                $existingQb = $pubRepo->findAllByCourse(
4177
                    $courseEntity,
4178
                    $sessionEntity,
4179
                    $title,
4180
                    null,
4181
                    'folder'
4182
                );
4183
                $existing = $existingQb
4184
                    ->andWhere('resource.publicationParent IS NULL')
4185
                    ->andWhere('resource.active IN (0,1)')
4186
                    ->setMaxResults(1)
4187
                    ->getQuery()
4188
                    ->getOneOrNullResult();
4189
4190
                if (!$existing) {
4191
                    $pub = new CStudentPublication();
4192
                    $pub->setTitle($title)
4193
                        ->setDescription($description)
4194
                        ->setFiletype('folder')
4195
                        ->setContainsFile(0)
4196
                        ->setWeight($weight)
4197
                        ->setQualification($qualification)
4198
                        ->setAllowTextAssignment($allowText)
4199
                        ->setDefaultVisibility($defaultVisibility)
4200
                        ->setStudentDeleteOwnPublication($studentMayDelete)
4201
                        ->setExtensions($extensions)
4202
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
4203
                        ->setPostGroupId($postGroupId);
4204
4205
                    if (method_exists($pub, 'setParent')) {
4206
                        $pub->setParent($courseEntity);
4207
                    }
4208
                    if (method_exists($pub, 'setCreator')) {
4209
                        $pub->setCreator(api_get_user_entity());
4210
                    }
4211
                    if (method_exists($pub, 'addCourseLink')) {
4212
                        $pub->addCourseLink($courseEntity, $sessionEntity);
4213
                    }
4214
4215
                    $em->persist($pub);
4216
                    $em->flush();
4217
4218
                    // Assignment
4219
                    $assignment = new CStudentPublicationAssignment();
4220
                    $assignment->setPublication($pub)
4221
                        ->setEnableQualification($enableQualification || $qualification > 0);
4222
4223
                    if ($expiresOn) { $assignment->setExpiresOn($expiresOn); }
4224
                    if ($endsOn)    { $assignment->setEndsOn($endsOn); }
4225
4226
                    $em->persist($assignment);
4227
                    $em->flush();
4228
4229
                    // Calendar (URL “Chamilo 2”: Router/UUID)
4230
                    if ($addToCalendar) {
4231
                        $eventTitle = sprintf(get_lang('Handing over of task %s'), $pub->getTitle());
4232
4233
                        // URL por UUID o Router
4234
                        $publicationUrl = null;
4235
                        $uuid = $pub->getResourceNode()?->getUuid();
4236
                        if ($uuid) {
4237
                            if (property_exists($this, 'router') && $this->router instanceof RouterInterface) {
4238
                                try {
4239
                                    $publicationUrl = $this->router->generate(
4240
                                        'student_publication_view',
4241
                                        ['uuid' => (string) $uuid],
4242
                                        UrlGeneratorInterface::ABSOLUTE_PATH
4243
                                    );
4244
                                } catch (\Throwable) {
4245
                                    $publicationUrl = '/r/student_publication/'. $uuid;
4246
                                }
4247
                            } else {
4248
                                $publicationUrl = '/r/student_publication/'. $uuid;
4249
                            }
4250
                        }
4251
4252
                        $content = sprintf(
4253
                            '<div>%s</div> %s',
4254
                            $publicationUrl
4255
                                ? sprintf('<a href="%s">%s</a>', $publicationUrl, $pub->getTitle())
4256
                                : htmlspecialchars($pub->getTitle(), ENT_QUOTES),
4257
                            $pub->getDescription()
4258
                        );
4259
4260
                        $start = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC'));
4261
                        $end   = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC'));
4262
4263
                        $color = CCalendarEvent::COLOR_STUDENT_PUBLICATION;
4264
                        if ($colors = api_get_setting('agenda.agenda_colors')) {
4265
                            if (!empty($colors['student_publication'])) {
4266
                                $color = $colors['student_publication'];
4267
                            }
4268
                        }
4269
4270
                        $event = (new CCalendarEvent())
4271
                            ->setTitle($eventTitle)
4272
                            ->setContent($content)
4273
                            ->setParent($courseEntity)
4274
                            ->setCreator($pub->getCreator())
4275
                            ->addLink(clone $pub->getFirstResourceLink())
4276
                            ->setStartDate($start)
4277
                            ->setEndDate($end)
4278
                            ->setColor($color);
4279
4280
                        $em->persist($event);
4281
                        $em->flush();
4282
4283
                        $assignment->setEventCalendarId((int)$event->getIid());
4284
                        $em->flush();
4285
                    }
4286
4287
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$pub->getIid();
4288
                } else {
4289
                    $existing
4290
                        ->setDescription($description)
4291
                        ->setWeight($weight)
4292
                        ->setQualification($qualification)
4293
                        ->setAllowTextAssignment($allowText)
4294
                        ->setDefaultVisibility($defaultVisibility)
4295
                        ->setStudentDeleteOwnPublication($studentMayDelete)
4296
                        ->setExtensions($extensions)
4297
                        ->setGroupCategoryWorkId($groupCategoryWorkId)
4298
                        ->setPostGroupId($postGroupId);
4299
4300
                    $em->persist($existing);
4301
                    $em->flush();
4302
4303
                    $assignment = $existing->getAssignment();
4304
                    if (!$assignment) {
4305
                        $assignment = new CStudentPublicationAssignment();
4306
                        $assignment->setPublication($existing);
4307
                        $em->persist($assignment);
4308
                    }
4309
4310
                    $assignment->setEnableQualification($enableQualification || $qualification > 0);
4311
                    $assignment->setExpiresOn($expiresOn);
4312
                    $assignment->setEndsOn($endsOn);
4313
                    if (!$addToCalendar) {
4314
                        $assignment->setEventCalendarId(0);
4315
                    }
4316
                    $em->flush();
4317
4318
                    $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$existing->getIid();
4319
                }
4320
            } catch (\Throwable $e) {
4321
                error_log('COURSE_DEBUG: restore_works: '.$e->getMessage());
4322
                continue;
4323
            }
4324
        }
4325
    }
4326
4327
4328
    public function restore_gradebook(int $sessionId = 0): void
4329
    {
4330
        if (\in_array($this->file_option, [FILE_SKIP, FILE_RENAME], true)) {
4331
            return;
4332
        }
4333
4334
        if (!$this->course->has_resources(RESOURCE_GRADEBOOK)) {
4335
            $this->dlog('restore_gradebook: no gradebook resources');
4336
            return;
4337
        }
4338
4339
        /** @var EntityManagerInterface $em */
4340
        $em = \Database::getManager();
4341
4342
        /** @var Course $courseEntity */
4343
        $courseEntity  = api_get_course_entity($this->destination_course_id);
4344
        /** @var SessionEntity|null $sessionEntity */
4345
        $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null;
4346
        /** @var User $currentUser */
4347
        $currentUser   = api_get_user_entity();
4348
4349
        $catRepo  = $em->getRepository(GradebookCategory::class);
4350
4351
        // 1) Clean destination (overwrite semantics)
4352
        try {
4353
            $existingCats = $catRepo->findBy([
4354
                'course'  => $courseEntity,
4355
                'session' => $sessionEntity,
4356
            ]);
4357
            foreach ($existingCats as $cat) {
4358
                $em->remove($cat); // cascades remove evaluations/links
4359
            }
4360
            $em->flush();
4361
            $this->dlog('restore_gradebook: destination cleaned', ['removed' => count($existingCats)]);
4362
        } catch (\Throwable $e) {
4363
            $this->dlog('restore_gradebook: clean failed (continuing)', ['error' => $e->getMessage()]);
4364
        }
4365
4366
        $oldIdToNewCat = [];
4367
4368
        // 2) First pass: create all categories (no parent yet)
4369
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
4370
            $categories = (array) ($gbItem->categories ?? []);
4371
            foreach ($categories as $rawCat) {
4372
                $c = is_array($rawCat) ? $rawCat : (array) $rawCat;
4373
4374
                $oldId   = (int)   ($c['id'] ?? $c['iid'] ?? 0);
4375
                $title   = (string)($c['title'] ?? 'Category');
4376
                $desc    = (string)($c['description'] ?? '');
4377
                $weight  = (float)  ($c['weight'] ?? 0.0);
4378
                $visible = (bool)   ($c['visible'] ?? true);
4379
                $locked  = (int)    ($c['locked'] ?? 0);
4380
4381
                $new = new GradebookCategory();
4382
                $new->setCourse($courseEntity);
4383
                $new->setSession($sessionEntity);
4384
                $new->setUser($currentUser);
4385
                $new->setTitle($title);
4386
                $new->setDescription($desc);
4387
                $new->setWeight($weight);
4388
                $new->setVisible($visible);
4389
                $new->setLocked($locked);
4390
4391
                // Optional fields if present in backup
4392
                if (isset($c['generate_certificates'])) {
4393
                    $new->setGenerateCertificates((bool)$c['generate_certificates']);
4394
                } elseif (isset($c['generateCertificates'])) {
4395
                    $new->setGenerateCertificates((bool)$c['generateCertificates']);
4396
                }
4397
                if (isset($c['certificate_validity_period'])) {
4398
                    $new->setCertificateValidityPeriod((int)$c['certificate_validity_period']);
4399
                } elseif (isset($c['certificateValidityPeriod'])) {
4400
                    $new->setCertificateValidityPeriod((int)$c['certificateValidityPeriod']);
4401
                }
4402
                if (isset($c['is_requirement'])) {
4403
                    $new->setIsRequirement((bool)$c['is_requirement']);
4404
                } elseif (isset($c['isRequirement'])) {
4405
                    $new->setIsRequirement((bool)$c['isRequirement']);
4406
                }
4407
                if (isset($c['default_lowest_eval_exclude'])) {
4408
                    $new->setDefaultLowestEvalExclude((bool)$c['default_lowest_eval_exclude']);
4409
                } elseif (isset($c['defaultLowestEvalExclude'])) {
4410
                    $new->setDefaultLowestEvalExclude((bool)$c['defaultLowestEvalExclude']);
4411
                }
4412
                if (array_key_exists('minimum_to_validate', $c)) {
4413
                    $new->setMinimumToValidate((int)$c['minimum_to_validate']);
4414
                } elseif (array_key_exists('minimumToValidate', $c)) {
4415
                    $new->setMinimumToValidate((int)$c['minimumToValidate']);
4416
                }
4417
                if (array_key_exists('gradebooks_to_validate_in_dependence', $c)) {
4418
                    $new->setGradeBooksToValidateInDependence((int)$c['gradebooks_to_validate_in_dependence']);
4419
                } elseif (array_key_exists('gradeBooksToValidateInDependence', $c)) {
4420
                    $new->setGradeBooksToValidateInDependence((int)$c['gradeBooksToValidateInDependence']);
4421
                }
4422
                if (array_key_exists('allow_skills_by_subcategory', $c)) {
4423
                    $new->setAllowSkillsBySubcategory((int)$c['allow_skills_by_subcategory']);
4424
                } elseif (array_key_exists('allowSkillsBySubcategory', $c)) {
4425
                    $new->setAllowSkillsBySubcategory((int)$c['allowSkillsBySubcategory']);
4426
                }
4427
                if (!empty($c['grade_model_id'])) {
4428
                    $gm = $em->find(GradeModel::class, (int)$c['grade_model_id']);
4429
                    if ($gm) { $new->setGradeModel($gm); }
4430
                }
4431
4432
                $em->persist($new);
4433
                $em->flush();
4434
4435
                if ($oldId > 0) {
4436
                    $oldIdToNewCat[$oldId] = $new;
4437
                }
4438
            }
4439
        }
4440
4441
        // 3) Second pass: wire parents
4442
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
4443
            $categories = (array) ($gbItem->categories ?? []);
4444
            foreach ($categories as $rawCat) {
4445
                $c = is_array($rawCat) ? $rawCat : (array) $rawCat;
4446
                $oldId     = (int)($c['id'] ?? $c['iid'] ?? 0);
4447
                $parentOld = (int)($c['parent_id'] ?? $c['parentId'] ?? 0);
4448
                if ($oldId > 0 && isset($oldIdToNewCat[$oldId]) && $parentOld > 0 && isset($oldIdToNewCat[$parentOld])) {
4449
                    $cat = $oldIdToNewCat[$oldId];
4450
                    $cat->setParent($oldIdToNewCat[$parentOld]);
4451
                    $em->persist($cat);
4452
                }
4453
            }
4454
        }
4455
        $em->flush();
4456
4457
        // 4) Evaluations + Links
4458
        foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) {
4459
            $categories = (array) ($gbItem->categories ?? []);
4460
            foreach ($categories as $rawCat) {
4461
                $c = is_array($rawCat) ? $rawCat : (array) $rawCat;
4462
                $oldId = (int)($c['id'] ?? $c['iid'] ?? 0);
4463
                if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) { continue; }
4464
4465
                $dstCat = $oldIdToNewCat[$oldId];
4466
4467
                // Evaluations
4468
                foreach ((array)($c['evaluations'] ?? []) as $rawEval) {
4469
                    $e = is_array($rawEval) ? $rawEval : (array) $rawEval;
4470
4471
                    $eval = new GradebookEvaluation();
4472
                    $eval->setCourse($courseEntity);
4473
                    $eval->setCategory($dstCat);
4474
                    $eval->setTitle((string)($e['title'] ?? 'Evaluation'));
4475
                    $eval->setDescription((string)($e['description'] ?? ''));
4476
                    $eval->setWeight((float)($e['weight'] ?? 0.0));
4477
                    $eval->setMax((float)($e['max'] ?? 100.0));
4478
                    $eval->setType((string)($e['type'] ?? 'manual'));
4479
                    $eval->setVisible((int)($e['visible'] ?? 1));
4480
                    $eval->setLocked((int)($e['locked'] ?? 0));
4481
4482
                    if (isset($e['best_score']))    { $eval->setBestScore((float)$e['best_score']); }
4483
                    if (isset($e['average_score'])) { $eval->setAverageScore((float)$e['average_score']); }
4484
                    if (isset($e['score_weight']))  { $eval->setScoreWeight((float)$e['score_weight']); }
4485
                    if (isset($e['min_score']))     { $eval->setMinScore((float)$e['min_score']); }
4486
4487
                    $em->persist($eval);
4488
                }
4489
4490
                // Links
4491
                foreach ((array)($c['links'] ?? []) as $rawLink) {
4492
                    $l = is_array($rawLink) ? $rawLink : (array) $rawLink;
4493
4494
                    $linkType  = (int)($l['type']   ?? $l['link_type'] ?? 0);
4495
                    $legacyRef = (int)($l['ref_id'] ?? $l['refId']     ?? 0);
4496
                    if ($linkType <= 0 || $legacyRef <= 0) {
4497
                        $this->dlog('restore_gradebook: skipping link (missing type/ref)', $l);
4498
                        continue;
4499
                    }
4500
4501
                    $resourceType = $this->gb_guessResourceTypeByLinkType($linkType);
4502
                    $newRefId     = $this->gb_resolveDestinationId($resourceType, $legacyRef);
4503
                    if ($newRefId <= 0) {
4504
                        $this->dlog('restore_gradebook: skipping link (no destination id)', ['type' => $linkType, 'legacyRef' => $legacyRef]);
4505
                        continue;
4506
                    }
4507
4508
                    $link = new GradebookLink();
4509
                    $link->setCourse($courseEntity);
4510
                    $link->setCategory($dstCat);
4511
                    $link->setType($linkType);
4512
                    $link->setRefId($newRefId);
4513
                    $link->setWeight((float)($l['weight'] ?? 0.0));
4514
                    $link->setVisible((int)($l['visible'] ?? 1));
4515
                    $link->setLocked((int)($l['locked'] ?? 0));
4516
4517
                    if (isset($l['best_score']))    { $link->setBestScore((float)$l['best_score']); }
4518
                    if (isset($l['average_score'])) { $link->setAverageScore((float)$l['average_score']); }
4519
                    if (isset($l['score_weight']))  { $link->setScoreWeight((float)$l['score_weight']); }
4520
                    if (isset($l['min_score']))     { $link->setMinScore((float)$l['min_score']); }
4521
4522
                    $em->persist($link);
4523
                }
4524
4525
                $em->flush();
4526
            }
4527
        }
4528
4529
        $this->dlog('restore_gradebook: done');
4530
    }
4531
4532
    /** Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources */
4533
    private function gb_guessResourceTypeByLinkType(int $linkType): ?int
4534
    {
4535
        return match ($linkType) {
4536
            LINK_EXERCISE            => RESOURCE_QUIZ,
4537
            LINK_STUDENTPUBLICATION  => RESOURCE_WORK,
4538
            LINK_LEARNPATH           => RESOURCE_LEARNPATH,
4539
            LINK_FORUM_THREAD        => RESOURCE_FORUMTOPIC,
4540
            LINK_ATTENDANCE          => RESOURCE_ATTENDANCE,
4541
            LINK_SURVEY              => RESOURCE_SURVEY,
4542
            LINK_HOTPOTATOES         => RESOURCE_QUIZ,
4543
            default                  => null,
4544
        };
4545
    }
4546
4547
    /** Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored) */
4548
    private function gb_resolveDestinationId(?int $type, int $legacyId): int
4549
    {
4550
        if (null === $type) { return 0; }
4551
        if (!$this->course->has_resources($type)) { return 0; }
4552
        $bucket = $this->course->resources[$type] ?? [];
4553
        if (!isset($bucket[$legacyId])) { return 0; }
4554
        $res = $bucket[$legacyId];
4555
        $destId = (int)($res->destination_id ?? 0);
4556
        return $destId > 0 ? $destId : 0;
4557
    }
4558
4559
4560
    /**
4561
     * Restore course assets (not included in documents).
4562
     */
4563
    public function restore_assets()
4564
    {
4565
        if ($this->course->has_resources(RESOURCE_ASSET)) {
4566
            $resources = $this->course->resources;
4567
            $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/';
0 ignored issues
show
Bug introduced by
The constant Chamilo\CourseBundle\Com...rseCopy\SYS_COURSE_PATH was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
4568
4569
            foreach ($resources[RESOURCE_ASSET] as $asset) {
4570
                if (is_file($this->course->backup_path.'/'.$asset->path) &&
4571
                    is_readable($this->course->backup_path.'/'.$asset->path) &&
4572
                    is_dir(dirname($path.$asset->path)) &&
4573
                    is_writeable(dirname($path.$asset->path))
4574
                ) {
4575
                    switch ($this->file_option) {
4576
                        case FILE_SKIP:
4577
                            break;
4578
                        case FILE_OVERWRITE:
4579
                            copy(
4580
                                $this->course->backup_path.'/'.$asset->path,
4581
                                $path.$asset->path
4582
                            );
4583
4584
                            break;
4585
                    }
4586
                }
4587
            }
4588
        }
4589
    }
4590
}
4591