Passed
Pull Request — master (#7027)
by
unknown
12:49 queued 03:07
created

ThematicMetaExport::appendToManifest()   A

Complexity

Conditions 6
Paths 10

Size

Total Lines 30
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 6
eloc 19
c 1
b 0
f 1
nc 10
nop 2
dl 0
loc 30
rs 9.0111
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
declare(strict_types=1);
5
6
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities;
7
8
/**
9
 * ThematicMetaExport
10
 *
11
 * Writes Chamilo-only metadata for Thematic into:
12
 *   chamilo/thematic/thematic_{moduleId}.json
13
 * and indexes it from chamilo/manifest.json. Moodle ignores this directory.
14
 *
15
 * Primary data source (CourseBuilder wrapper shape):
16
 *   - $thematic->params:               ['id','title','content','active']
17
 *   - $thematic->thematic_advance_list: list of advances (array of arrays)
18
 *   - $thematic->thematic_plan_list:    list of plans (array of arrays)
19
 *
20
 * Also supports the "legacy/domain" shape as a fallback.
21
 */
22
class ThematicMetaExport extends ActivityExport
23
{
24
    public function export(int $activityId, string $exportDir, int $moduleId, int $sectionId): void
25
    {
26
        $thematic = $this->findThematicById($activityId);
27
        if ($thematic === null) {
28
            @error_log('[ThematicMetaExport] Skipping: thematic not found id=' . $activityId);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for error_log(). 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

28
            /** @scrutinizer ignore-unhandled */ @error_log('[ThematicMetaExport] Skipping: thematic not found id=' . $activityId);

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...
29
            return;
30
        }
31
32
        $payload = $this->buildPayloadFromLegacy($thematic, $moduleId, $sectionId);
33
34
        $base = rtrim($exportDir, '/') . '/chamilo/thematic';
35
        if (!is_dir($base)) {
36
            @mkdir($base, (int)octdec('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

36
            /** @scrutinizer ignore-unhandled */ @mkdir($base, (int)octdec('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...
37
        }
38
39
        $jsonFile = $base . '/thematic_' . $moduleId . '.json';
40
        @file_put_contents(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). 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

40
        /** @scrutinizer ignore-unhandled */ @file_put_contents(

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...
41
            $jsonFile,
42
            json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
43
        );
44
45
        $this->appendToManifest($exportDir, [
46
            'kind'      => 'thematic',
47
            'moduleid'  => $moduleId,
48
            'sectionid' => $sectionId,
49
            'title'     => (string)($payload['title'] ?? 'Thematic'),
50
            'path'      => 'chamilo/thematic/thematic_' . $moduleId . '.json',
51
        ]);
52
53
        @error_log('[ThematicMetaExport] Exported thematic moduleid=' . $moduleId . ' sectionid=' . $sectionId);
54
    }
55
56
    /**
57
     * Find thematic by iid across both shapes:
58
     *  - CourseBuilder wrapper: params['id']
59
     *  - Legacy object: id/iid/source_id
60
     */
61
    private function findThematicById(int $iid): ?object
62
    {
63
        $bag = $this->course->resources[\defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic']
64
            ?? $this->course->resources['thematic']
65
            ?? [];
66
67
        if (!\is_array($bag)) {
68
            return null;
69
        }
70
71
        foreach ($bag as $maybe) {
72
            if (!\is_object($maybe)) {
73
                continue;
74
            }
75
76
            // Direct match on wrapper params['id']
77
            $params = $this->readParams($maybe);
78
            $pid    = (int)($params['id'] ?? 0);
79
            if ($pid === $iid) {
80
                return $maybe;
81
            }
82
83
            // Fallback to object/id/iid/source_id
84
            $obj = (isset($maybe->obj) && \is_object($maybe->obj)) ? $maybe->obj : $maybe;
85
            $candidates = [
86
                (int)($obj->id ?? 0),
87
                (int)($obj->iid ?? 0),
88
                (int)($obj->source_id ?? 0),
89
            ];
90
            if (\in_array($iid, $candidates, true)) {
91
                return $obj;
92
            }
93
        }
94
95
        return null;
96
    }
97
98
    /**
99
     * Build payload from the CourseBuilder wrapper first; fallback to legacy getters/props.
100
     */
101
    private function buildPayloadFromLegacy(object $t, int $moduleId, int $sectionId): array
102
    {
103
        // ---- PRIMARY: CourseBuilder wrapper shape ----
104
        $params   = $this->readParams($t);
105
        $title    = (string)($params['title']   ?? $this->readFirst($t, ['title','name'], 'Thematic'));
106
        $content  = (string)($params['content'] ?? $this->readFirst($t, ['content','summary','description','intro'], ''));
107
        $active   = (int)((isset($params['active']) ? (int)$params['active'] : ($t->active ?? 1)));
108
109
        // Lists from wrapper keys
110
        $advanceList = $this->readList($t, [
111
            'thematic_advance_list', // exact key from your dump
112
            'thematic_advances',
113
            'advances',
114
        ]);
115
116
        $planList = $this->readList($t, [
117
            'thematic_plan_list', // exact key from your dump
118
            'thematic_plans',
119
            'plans',
120
        ]);
121
122
        // Normalize collections
123
        $advances = $this->normalizeAdvances($advanceList);
124
        $plans    = $this->normalizePlans($planList);
125
126
        // Derive optional semantics (objective/outcomes) from plans if available
127
        [$objective, $outcomes] = $this->deriveObjectiveAndOutcomes($plans);
128
129
        // Collect cross-links (documents/assign/quiz/forums/URLs) from texts
130
        $links = array_values($this->uniqueByHash(array_merge(
131
            $this->collectLinksFromText($content),
132
            ...array_map(fn($a) => $this->collectLinksFromText((string)($a['content'] ?? '')), $advances),
133
            ...array_map(fn($p) => $this->collectLinksFromText((string)($p['description'] ?? '')), $plans)
134
        )));
135
136
        return [
137
            'type'        => 'thematic',
138
            'moduleid'    => $moduleId,
139
            'sectionid'   => $sectionId,
140
            'title'       => $title,
141
            'content'     => $content,
142
            'active'      => $active,
143
            'objective'   => $objective,
144
            'outcomes'    => $outcomes,
145
            'advances'    => $advances,
146
            'plans'       => $plans,
147
            'links'       => $links,
148
            '_exportedAt' => date('c'),
149
        ];
150
    }
151
152
    /** Read $obj->params as array if present (wrapper shape). */
153
    private function readParams(object $obj): array
154
    {
155
        // Direct property
156
        if (isset($obj->params) && \is_array($obj->params)) {
157
            return $obj->params;
158
        }
159
        // Common getters
160
        foreach (['getParams','get_params'] as $m) {
161
            if (\is_callable([$obj, $m])) {
162
                try {
163
                    $v = $obj->{$m}();
164
                    if (\is_array($v)) {
165
                        return $v;
166
                    }
167
                } catch (\Throwable) { /* ignore */ }
168
            }
169
        }
170
        return [];
171
    }
172
173
    /** Read a list from any of the given property names or simple getters. */
174
    private function readList(object $obj, array $propNames): array
175
    {
176
        foreach ($propNames as $k) {
177
            if (isset($obj->{$k})) {
178
                $v = $obj->{$k};
179
                if (\is_array($v)) {
180
                    return $v;
181
                }
182
                if ($v instanceof \Traversable) {
183
                    return iterator_to_array($v);
184
                }
185
            }
186
            $getter = 'get' . str_replace(' ', '', ucwords(str_replace(['_','-'], ' ', $k)));
187
            if (\is_callable([$obj, $getter])) {
188
                try {
189
                    $v = $obj->{$getter}();
190
                    if (\is_array($v)) {
191
                        return $v;
192
                    }
193
                    if ($v instanceof \Traversable) {
194
                        return iterator_to_array($v);
195
                    }
196
                } catch (\Throwable) { /* ignore */ }
197
            }
198
        }
199
        return [];
200
    }
201
202
    /** Fallback string reader from object props; returns $fallback when empty. */
203
    private function readFirst(object $o, array $propNames, string $fallback = ''): string
204
    {
205
        foreach ($propNames as $k) {
206
            if (isset($o->{$k}) && \is_string($o->{$k})) {
207
                $v = trim($o->{$k});
208
                if ($v !== '') {
209
                    return $v;
210
                }
211
            }
212
        }
213
        return $fallback;
214
    }
215
216
    /** Normalize advances array (array-of-arrays OR array-of-objects). */
217
    private function normalizeAdvances(array $list): array
218
    {
219
        $out = [];
220
        foreach ($list as $it) {
221
            if (!\is_array($it) && !\is_object($it)) {
222
                continue;
223
            }
224
            $a = (array)$it; // array cast works for stdClass and most DTOs
225
            $id       = (int)($a['id']           ?? ($a['iid'] ?? 0));
226
            $themid   = (int)($a['thematic_id']  ?? 0);
227
            $content  = (string)($a['content']   ?? '');
228
            $start    = (string)($a['start_date']?? '');
229
            $duration = (int)($a['duration']     ?? 0);
230
            $done     = (bool)($a['done_advance']?? false);
231
            $attid    = (int)($a['attendance_id']?? 0);
232
            $roomId   = (int)($a['room_id']      ?? 0);
233
234
            $out[] = [
235
                'id'            => $id,
236
                'thematic_id'   => $themid,
237
                'content'       => $content,
238
                'start_date'    => $start,
239
                'start_iso8601' => $this->toIso($start),
240
                'duration'      => $duration,
241
                'done_advance'  => $done,
242
                'attendance_id' => $attid,
243
                'room_id'       => $roomId,
244
            ];
245
        }
246
247
        usort($out, function ($a, $b) {
248
            if (($a['id'] ?? 0) !== ($b['id'] ?? 0)) {
249
                return ($a['id'] ?? 0) <=> ($b['id'] ?? 0);
250
            }
251
            return strcmp((string)($a['start_date'] ?? ''), (string)($b['start_date'] ?? ''));
252
        });
253
254
        return $out;
255
    }
256
257
    /** Normalize plans array (array-of-arrays OR array-of-objects). */
258
    private function normalizePlans(array $list): array
259
    {
260
        $out = [];
261
        foreach ($list as $it) {
262
            if (!\is_array($it) && !\is_object($it)) {
263
                continue;
264
            }
265
            $p = (array)$it;
266
            $id     = (int)($p['id']              ?? ($p['iid'] ?? 0));
267
            $themid = (int)($p['thematic_id']     ?? 0);
268
            $title  = (string)($p['title']        ?? '');
269
            $desc   = (string)($p['description']  ?? '');
270
            $dtype  = (int)($p['description_type']?? 0);
271
272
            $out[] = [
273
                'id'               => $id,
274
                'thematic_id'      => $themid,
275
                'title'            => $title,
276
                'description'      => $this->normalizePlanText($desc),
277
                'description_type' => $dtype,
278
            ];
279
        }
280
281
        usort($out, fn($a, $b) => ($a['id'] ?? 0) <=> ($b['id'] ?? 0));
282
        return $out;
283
    }
284
285
    /** Very light HTML/whitespace normalization. */
286
    private function normalizePlanText(string $s): string
287
    {
288
        $s = preg_replace('/[ \t]+/', ' ', (string)$s);
289
        return trim($s ?? '');
290
    }
291
292
    /** Derive objective/outcomes from plans per description_type codes (1=objective, 2=outcomes). */
293
    private function deriveObjectiveAndOutcomes(array $plans): array
294
    {
295
        $objective = '';
296
        $outcomes  = [];
297
298
        foreach ($plans as $p) {
299
            $type = (int)($p['description_type'] ?? 0);
300
            $ttl  = trim((string)($p['title'] ?? ''));
301
            $txt  = trim((string)($p['description'] ?? ''));
302
303
            if ($type === 1 && $objective === '') {
304
                $objective = $ttl !== '' ? $ttl : $txt;
305
            } elseif ($type === 2) {
306
                $outcomes[] = $ttl !== '' ? $ttl : $txt;
307
            }
308
        }
309
310
        // Clean and deduplicate outcomes
311
        $outcomes = array_values(array_unique(array_filter($outcomes, fn($x) => $x !== '')));
312
313
        return [$objective, $outcomes];
314
    }
315
316
    /** Collect cheap cross-links from HTML/text. */
317
    private function collectLinksFromText(string $html): array
318
    {
319
        $found = [];
320
        $patterns = [
321
            ['type' => 'document', 're' => '/(?:document\/|doc:)(\d+)/i'],
322
            ['type' => 'quiz',     're' => '/(?:quiz\/|quiz:)(\d+)/i'],
323
            ['type' => 'assign',   're' => '/(?:assign\/|assign:)(\d+)/i'],
324
            ['type' => 'forum',    're' => '/(?:forum\/|forum:)(\d+)/i'],
325
            ['type' => 'url',      're' => '/https?:\/\/[\w\-\.\:]+[^\s"<>\']*/i'],
326
        ];
327
328
        foreach ($patterns as $p) {
329
            if ($p['type'] === 'url') {
330
                if (preg_match_all($p['re'], (string)$html, $m)) {
331
                    foreach ($m[0] as $u) {
332
                        $found[] = ['type' => 'url', 'href' => (string)$u];
333
                    }
334
                }
335
            } else {
336
                if (preg_match_all($p['re'], (string)$html, $m)) {
337
                    foreach ($m[1] as $id) {
338
                        $id = (int)$id;
339
                        if ($id > 0) {
340
                            $found[] = ['type' => $p['type'], 'id' => $id];
341
                        }
342
                    }
343
                }
344
            }
345
        }
346
347
        return $found;
348
    }
349
350
    /** Convert 'Y-m-d H:i:s' or 'Y-m-d' to ISO 8601 if possible. */
351
    private function toIso(string $s): ?string
352
    {
353
        $s = trim($s);
354
        if ($s === '') {
355
            return null;
356
        }
357
        $ts = strtotime($s);
358
        return $ts ? date('c', $ts) : null;
359
    }
360
361
    /** De-duplicate link arrays by hashing. */
362
    private function uniqueByHash(array $items): array
363
    {
364
        $seen = [];
365
        $out  = [];
366
        foreach ($items as $it) {
367
            $key = md5(json_encode($it));
368
            if (!isset($seen[$key])) {
369
                $seen[$key] = true;
370
                $out[] = $it;
371
            }
372
        }
373
        return $out;
374
    }
375
376
    /** Append to chamilo/manifest.json (create if missing). */
377
    private function appendToManifest(string $exportDir, array $record): void
378
    {
379
        $dir = rtrim($exportDir, '/') . '/chamilo';
380
        if (!is_dir($dir)) {
381
            @mkdir($dir, (int)octdec('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

381
            /** @scrutinizer ignore-unhandled */ @mkdir($dir, (int)octdec('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...
382
        }
383
384
        $manifestFile = $dir . '/manifest.json';
385
        $manifest = [
386
            'version'     => 1,
387
            'exporter'    => 'C2-MoodleExport',
388
            'generatedAt' => date('c'),
389
            'items'       => [],
390
        ];
391
392
        if (is_file($manifestFile)) {
393
            $decoded = json_decode((string)file_get_contents($manifestFile), true);
394
            if (\is_array($decoded)) {
395
                $manifest = array_replace_recursive($manifest, $decoded);
396
            }
397
            if (!isset($manifest['items']) || !\is_array($manifest['items'])) {
398
                $manifest['items'] = [];
399
            }
400
        }
401
402
        $manifest['items'][] = $record;
403
404
        @file_put_contents(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). 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

404
        /** @scrutinizer ignore-unhandled */ @file_put_contents(

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...
405
            $manifestFile,
406
            json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
407
        );
408
    }
409
}
410