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

GradebookMetaExport::readCategories()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 5
eloc 9
c 1
b 0
f 1
nc 6
nop 1
dl 0
loc 21
rs 9.6111
1
<?php
2
/* For licensing terms, see /license.txt */
3
declare(strict_types=1);
4
5
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities;
6
7
/**
8
 * GradebookMetaExport
9
 *
10
 * Writes Chamilo-only metadata for the Gradebook into:
11
 *   {exportDir}/chamilo/gradebook/gradebook_{moduleId}.json
12
 * and indexes it from {exportDir}/chamilo/manifest.json.
13
 *
14
 * Moodle ignores the "chamilo/" directory; this is for Chamilo re-import only.
15
 *
16
 * Primary data source (CourseBuilder wrapper):
17
 *   - GradeBookBackup-like resource exposing a "categories" array (already serialized by the builder).
18
 */
19
class GradebookMetaExport extends ActivityExport
20
{
21
    /**
22
     * Export one Gradebook snapshot as JSON + manifest entry.
23
     *
24
     * @param int    $activityId Legacy/opaque wrapper id (not strictly required)
25
     * @param string $exportDir  Absolute temp export directory (root of backup)
26
     * @param int    $moduleId   Synthetic module id used to name files
27
     * @param int    $sectionId  Section (topic) id; informative for manifest
28
     */
29
    public function export(int $activityId, string $exportDir, int $moduleId, int $sectionId): void
30
    {
31
        $backup = $this->findGradebookBackup($activityId);
32
        if ($backup === null) {
33
            @error_log('[GradebookMetaExport] Skip: gradebook backup not found for activityId=' . $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

33
            /** @scrutinizer ignore-unhandled */ @error_log('[GradebookMetaExport] Skip: gradebook backup not found for activityId=' . $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...
34
            return;
35
        }
36
37
        $payload = $this->buildPayloadFromBackup($backup, $moduleId, $sectionId);
38
39
        // Ensure base dir exists: {exportDir}/chamilo/gradebook
40
        $base = rtrim($exportDir, '/') . '/chamilo/gradebook';
41
        if (!\is_dir($base)) {
42
            @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

42
            /** @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...
43
        }
44
45
        // Write JSON: chamilo/gradebook/gradebook_{moduleId}.json
46
        $jsonFile = $base . '/gradebook_' . $moduleId . '.json';
47
        @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

47
        /** @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...
48
            $jsonFile,
49
            \json_encode($payload, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT)
50
        );
51
52
        // Append entry to chamilo/manifest.json
53
        $this->appendToManifest($exportDir, [
54
            'kind'      => 'gradebook',
55
            'moduleid'  => $moduleId,
56
            'sectionid' => $sectionId,
57
            'title'     => 'Gradebook',
58
            'path'      => 'chamilo/gradebook/gradebook_' . $moduleId . '.json',
59
        ]);
60
61
        @error_log('[GradebookMetaExport] Exported gradebook meta moduleId=' . $moduleId . ' sectionId=' . $sectionId);
62
    }
63
64
    /**
65
     * Locate the GradeBookBackup wrapper from the CourseBuilder bag.
66
     * Robust rules:
67
     *  - Accept constant or string keys ("gradebook", "Gradebook").
68
     *  - If there is only ONE entry, return its first value without assuming index 0.
69
     *  - Otherwise, loosely match by "source_id" or "id" against $iid (string tolerant).
70
     *  - Finally, return the first object that exposes a "categories" array.
71
     */
72
    private function findGradebookBackup(int $iid): ?object
73
    {
74
        $resources = \is_array($this->course->resources ?? null) ? $this->course->resources : [];
75
76
        // Resolve "gradebook" bag defensively
77
        $bag =
78
            ($resources[\defined('RESOURCE_GRADEBOOK') ? \constant('RESOURCE_GRADEBOOK') : 'gradebook'] ?? null)
79
            ?? ($resources['gradebook'] ?? null)
80
            ?? ($resources['Gradebook'] ?? null)
81
            ?? [];
82
83
        if (!\is_array($bag) || empty($bag)) {
84
            return null;
85
        }
86
87
        // Fast path: single element but do not assume index 0
88
        if (\count($bag) === 1) {
89
            $first = \reset($bag); // returns first value regardless of key
90
            return \is_object($first) ? $first : null;
91
        }
92
93
        // Try to match loosely by id/source_id (string/numeric tolerant)
94
        foreach ($bag as $maybe) {
95
            if (!\is_object($maybe)) {
96
                continue;
97
            }
98
            $sid = null;
99
100
            // Many wrappers expose 'source_id'
101
            if (isset($maybe->source_id)) {
102
                $sid = (string) $maybe->source_id;
103
            } elseif (isset($maybe->id)) {
104
                // Some wrappers store an 'id' field on the object
105
                $sid = (string) $maybe->id;
106
            }
107
108
            if ($sid !== null && $sid !== '') {
109
                if ($sid === (string) $iid) {
110
                    return $maybe;
111
                }
112
            }
113
        }
114
115
        // Fallback: pick first object that has a categories array (GradeBookBackup shape)
116
        foreach ($bag as $maybe) {
117
            if (\is_object($maybe) && isset($maybe->categories) && \is_array($maybe->categories)) {
118
                return $maybe;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $maybe returns the type object which is incompatible with the type-hinted return null|object.
Loading history...
119
            }
120
        }
121
122
        return null;
123
    }
124
125
    /**
126
     * Build JSON payload from GradeBookBackup wrapper.
127
     * The wrapper already contains the serialized array produced by the builder.
128
     * We pass it through, applying minimal normalization.
129
     *
130
     * Additionally, we compute a best-effort list of "assessed_refs" pointing to
131
     * referenced activities (e.g., quiz/assign ids) if such hints are present in the categories.
132
     */
133
    private function buildPayloadFromBackup(object $backup, int $moduleId, int $sectionId): array
134
    {
135
        $categories = $this->readCategories($backup);
136
        $assessed = $this->computeAssessedRefsFromCategories($categories);
137
138
        return [
139
            'type'          => 'gradebook',
140
            'moduleid'      => $moduleId,
141
            'sectionid'     => $sectionId,
142
            'title'         => 'Gradebook',
143
            'categories'    => $categories,     // structure as produced by serializeGradebookCategory()
144
            'assessed_refs' => $assessed,       // best-effort references for Chamilo re-import
145
            '_exportedAt'   => \date('c'),
146
        ];
147
    }
148
149
    /**
150
     * Read and normalize categories from the wrapper.
151
     * Accepts arrays, Traversables and shallow objects of arrays.
152
     */
153
    private function readCategories(object $backup): array
154
    {
155
        // Direct property first
156
        if (isset($backup->categories)) {
157
            return $this->deepArray($backup->categories);
158
        }
159
160
        // Common getters
161
        foreach (['getCategories', 'get_categories'] as $m) {
162
            if (\is_callable([$backup, $m])) {
163
                try {
164
                    $v = $backup->{$m}();
165
                    return $this->deepArray($v);
166
                } catch (\Throwable) {
167
                    // ignore and continue
168
                }
169
            }
170
        }
171
172
        // Nothing found
173
        return [];
174
    }
175
176
    /**
177
     * Convert input into a JSON-safe array recursively.
178
     * - Arrays are copied deeply
179
     * - Traversables become arrays
180
     * - StdClass/DTOs with public props are cast to (array) and normalized
181
     * Note: we intentionally DO NOT traverse Doctrine entities here; the builder already serialized them.
182
     */
183
    private function deepArray(mixed $value): array
184
    {
185
        if (\is_array($value)) {
186
            $out = [];
187
            foreach ($value as $k => $v) {
188
                // inner values may be arrays or scalars; recurse only for arrays/objects/traversables
189
                if (\is_array($v) || $v instanceof \Traversable || \is_object($v)) {
190
                    $out[$k] = $this->deepArray($v);
191
                } else {
192
                    $out[$k] = $v;
193
                }
194
            }
195
            return $out;
196
        }
197
198
        if ($value instanceof \Traversable) {
199
            return $this->deepArray(\iterator_to_array($value));
200
        }
201
202
        if (\is_object($value)) {
203
            // Cast public properties, then normalize
204
            return $this->deepArray((array) $value);
205
        }
206
207
        // If a scalar reaches here at the top-level, normalize to array
208
        return [$value];
209
    }
210
211
    /**
212
     * Attempt to derive a minimal set of references to assessed activities
213
     * from the categories structure. This is *best-effort* and will only
214
     * collect what is already serialized by the builder.
215
     *
216
     * Output example:
217
     * [
218
     *   {"type":"quiz","id":123},
219
     *   {"type":"assign","id":45}
220
     * ]
221
     */
222
    private function computeAssessedRefsFromCategories(array $categories): array
223
    {
224
        $out = [];
225
        $seen = [];
226
227
        $push = static function (string $type, int $id) use (&$out, &$seen): void {
228
            if ($id <= 0 || $type === '') {
229
                return;
230
            }
231
            $key = $type . ':' . $id;
232
            if (isset($seen[$key])) {
233
                return;
234
            }
235
            $seen[$key] = true;
236
            $out[] = ['type' => $type, 'id' => $id];
237
        };
238
239
        $walk = function ($node) use (&$walk, $push): void {
240
            if (\is_array($node)) {
241
                // Heuristic: look for common keys that the builder might have serialized
242
                $typeKeys = ['item_type', 'resource_type', 'tool', 'type', 'modulename'];
243
                $idKeys   = ['item_id', 'resource_id', 'source_id', 'ref_id', 'id', 'iid', 'moduleid'];
244
245
                $type = '';
246
                foreach ($typeKeys as $k) {
247
                    if (isset($node[$k]) && \is_string($node[$k]) && $node[$k] !== '') {
248
                        $type = \strtolower(\trim((string) $node[$k]));
249
                        break;
250
                    }
251
                }
252
253
                $id = 0;
254
                foreach ($idKeys as $k) {
255
                    if (isset($node[$k]) && \is_numeric($node[$k])) {
256
                        $id = (int) $node[$k];
257
                        break;
258
                    }
259
                }
260
261
                // Allow a few known aliases
262
                $aliases = [
263
                    'exercise' => 'quiz',
264
                    'work'     => 'assign',
265
                ];
266
                if (isset($aliases[$type])) {
267
                    $type = $aliases[$type];
268
                }
269
270
                // Only record reasonable pairs (e.g., quiz/assign/wiki/resource/url)
271
                if ($type !== '' && $id > 0) {
272
                    $push($type, $id);
273
                }
274
275
                // Recurse into children/columns/items if present
276
                foreach ($node as $v) {
277
                    if (\is_array($v)) {
278
                        $walk($v);
279
                    }
280
                }
281
            }
282
        };
283
284
        $walk($categories);
285
286
        return $out;
287
    }
288
289
    /**
290
     * Append record to chamilo/manifest.json (create if missing).
291
     */
292
    private function appendToManifest(string $exportDir, array $record): void
293
    {
294
        $dir = rtrim($exportDir, '/') . '/chamilo';
295
        if (!\is_dir($dir)) {
296
            @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

296
            /** @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...
297
        }
298
299
        $manifestFile = $dir . '/manifest.json';
300
        $manifest = [
301
            'version'     => 1,
302
            'exporter'    => 'C2-MoodleExport',
303
            'generatedAt' => \date('c'),
304
            'items'       => [],
305
        ];
306
307
        if (\is_file($manifestFile)) {
308
            $decoded = \json_decode((string) \file_get_contents($manifestFile), true);
309
            if (\is_array($decoded)) {
310
                // Merge with defaults but preserve existing 'items'
311
                $manifest = \array_replace_recursive($manifest, $decoded);
312
            }
313
            if (!isset($manifest['items']) || !\is_array($manifest['items'])) {
314
                $manifest['items'] = [];
315
            }
316
        }
317
318
        $manifest['items'][] = $record;
319
320
        @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

320
        /** @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...
321
            $manifestFile,
322
            \json_encode($manifest, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT)
323
        );
324
    }
325
}
326