1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
/* For licensing terms, see /license.txt */ |
6
|
|
|
|
7
|
|
|
namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder; |
8
|
|
|
|
9
|
|
|
use Chamilo\CoreBundle\Framework\Container; |
10
|
|
|
use Chamilo\CourseBundle\Entity\CDocument; |
11
|
|
|
use DocumentManager; |
12
|
|
|
use Exception; |
13
|
|
|
|
14
|
|
|
use const PATHINFO_EXTENSION; |
15
|
|
|
use const PHP_EOL; |
16
|
|
|
|
17
|
|
|
/** |
18
|
|
|
* Class FileExport. |
19
|
|
|
* Handles the export of files and metadata from Moodle courses. |
20
|
|
|
*/ |
21
|
|
|
class FileExport |
22
|
|
|
{ |
23
|
|
|
/** |
24
|
|
|
* @var object |
25
|
|
|
*/ |
26
|
|
|
private $course; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* Constructor to initialize course data. |
30
|
|
|
* |
31
|
|
|
* @param object $course course object containing resources and path data |
32
|
|
|
*/ |
33
|
|
|
public function __construct(object $course) |
34
|
|
|
{ |
35
|
|
|
$this->course = $course; |
36
|
|
|
} |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* Export files and metadata from files.xml to the specified directory. |
40
|
|
|
*/ |
41
|
|
|
public function exportFiles(array $filesData, string $exportDir): void |
42
|
|
|
{ |
43
|
|
|
$filesDir = $exportDir.'/files'; |
44
|
|
|
if (!is_dir($filesDir)) { |
45
|
|
|
mkdir($filesDir, api_get_permissions_for_new_directories(), true); |
46
|
|
|
} |
47
|
|
|
$this->createPlaceholderFile($filesDir); |
48
|
|
|
|
49
|
|
|
$unique = ['files' => []]; |
50
|
|
|
$seenKeys = []; // string => true |
51
|
|
|
|
52
|
|
|
foreach (($filesData['files'] ?? []) as $file) { |
53
|
|
|
$ch = (string) ($file['contenthash'] ?? ''); |
54
|
|
|
$comp = (string) ($file['component'] ?? ''); |
55
|
|
|
$area = (string) ($file['filearea'] ?? ''); |
56
|
|
|
$path = $this->ensureTrailingSlash((string) ($file['filepath'] ?? '/')); |
57
|
|
|
$name = (string) ($file['filename'] ?? ''); |
58
|
|
|
|
59
|
|
|
if ('' === $ch || '' === $name) { |
60
|
|
|
continue; |
61
|
|
|
} |
62
|
|
|
|
63
|
|
|
$dedupeKey = implode('|', [$ch, $comp, $area, $path, $name]); |
64
|
|
|
if (isset($seenKeys[$dedupeKey])) { |
65
|
|
|
continue; |
66
|
|
|
} |
67
|
|
|
$seenKeys[$dedupeKey] = true; |
68
|
|
|
|
69
|
|
|
FileIndex::register($file); |
70
|
|
|
|
71
|
|
|
$file['filepath'] = $path; |
72
|
|
|
$unique['files'][] = $file; |
73
|
|
|
} |
74
|
|
|
|
75
|
|
|
foreach ($unique['files'] as $file) { |
76
|
|
|
$ch = (string) $file['contenthash']; |
77
|
|
|
$subdir = FileIndex::resolveSubdirByContenthash($ch); |
78
|
|
|
$this->copyFileToExportDir($file, $filesDir, $subdir); |
79
|
|
|
} |
80
|
|
|
|
81
|
|
|
$this->createFilesXml($unique, $exportDir); |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* Get file data from course resources. This is for testing purposes. |
86
|
|
|
* |
87
|
|
|
* @return array<string,mixed> |
88
|
|
|
*/ |
89
|
|
|
public function getFilesData(): array |
90
|
|
|
{ |
91
|
|
|
$adminData = MoodleExport::getAdminUserData(); |
92
|
|
|
$adminId = $adminData['id'] ?? 0; |
93
|
|
|
|
94
|
|
|
$filesData = ['files' => []]; |
95
|
|
|
|
96
|
|
|
// Defensive read: documents may be missing |
97
|
|
|
$docResources = $this->course->resources[RESOURCE_DOCUMENT] ?? []; |
98
|
|
|
if (!\is_array($docResources)) { |
99
|
|
|
$docResources = []; |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
foreach ($docResources as $document) { |
103
|
|
|
$filesData = $this->processDocument($filesData, $document); |
104
|
|
|
} |
105
|
|
|
|
106
|
|
|
// Defensive read: works may be missing (avoids "Undefined array key 'work'") |
107
|
|
|
$workResources = $this->course->resources[RESOURCE_WORK] ?? []; |
108
|
|
|
if (!\is_array($workResources)) { |
109
|
|
|
$workResources = []; |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
foreach ($workResources as $work) { |
113
|
|
|
// getAllDocumentToWork might not exist in some installs; guard it |
114
|
|
|
$workFiles = \function_exists('getAllDocumentToWork') |
115
|
|
|
? (getAllDocumentToWork($work->params['id'] ?? 0, $this->course->info['real_id'] ?? 0) ?: []) |
116
|
|
|
: []; |
117
|
|
|
|
118
|
|
|
if (!\is_array($workFiles) || empty($workFiles)) { |
119
|
|
|
continue; |
120
|
|
|
} |
121
|
|
|
|
122
|
|
|
foreach ($workFiles as $file) { |
123
|
|
|
// Safely fetch doc data |
124
|
|
|
$docId = (int) ($file['document_id'] ?? 0); |
125
|
|
|
if ($docId <= 0) { |
126
|
|
|
continue; |
127
|
|
|
} |
128
|
|
|
|
129
|
|
|
$docData = DocumentManager::get_document_data_by_id( |
|
|
|
|
130
|
|
|
$docId, |
131
|
|
|
(string) ($this->course->info['code'] ?? '') |
132
|
|
|
); |
133
|
|
|
|
134
|
|
|
if (!\is_array($docData) || empty($docData['path'])) { |
135
|
|
|
continue; |
136
|
|
|
} |
137
|
|
|
|
138
|
|
|
$filesData['files'][] = [ |
139
|
|
|
'id' => $docId, |
140
|
|
|
'contenthash' => hash('sha1', basename($docData['path'])), |
141
|
|
|
'contextid' => (int) ($this->course->info['real_id'] ?? 0), |
142
|
|
|
'component' => 'mod_assign', |
143
|
|
|
'filearea' => 'introattachment', |
144
|
|
|
'itemid' => (int) ($work->params['id'] ?? 0), |
145
|
|
|
'filepath' => '/Documents/', |
146
|
|
|
'documentpath' => 'document/'.$docData['path'], |
147
|
|
|
'filename' => basename($docData['path']), |
148
|
|
|
'userid' => $adminId, |
149
|
|
|
'filesize' => (int) ($docData['size'] ?? 0), |
150
|
|
|
'mimetype' => $this->getMimeType($docData['path']), |
151
|
|
|
'status' => 0, |
152
|
|
|
'timecreated' => time() - 3600, |
153
|
|
|
'timemodified' => time(), |
154
|
|
|
'source' => (string) ($docData['title'] ?? ''), |
155
|
|
|
'author' => 'Unknown', |
156
|
|
|
'license' => 'allrightsreserved', |
157
|
|
|
]; |
158
|
|
|
} |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
return $filesData; |
162
|
|
|
} |
163
|
|
|
|
164
|
|
|
/** |
165
|
|
|
* Create a placeholder index.html file to prevent an empty directory. |
166
|
|
|
*/ |
167
|
|
|
private function createPlaceholderFile(string $filesDir): void |
168
|
|
|
{ |
169
|
|
|
$placeholderFile = $filesDir.'/index.html'; |
170
|
|
|
file_put_contents($placeholderFile, '<!-- Placeholder file to ensure the directory is not empty -->'); |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
/** |
174
|
|
|
* Copy a file to the export directory using its contenthash. |
175
|
|
|
* |
176
|
|
|
* @param array<string,mixed> $file |
177
|
|
|
*/ |
178
|
|
|
private function copyFileToExportDir(array $file, string $filesDir, ?string $precomputedSubdir = null): void |
179
|
|
|
{ |
180
|
|
|
if (($file['filepath'] ?? '.') === '.') { |
181
|
|
|
return; |
182
|
|
|
} |
183
|
|
|
|
184
|
|
|
$contenthash = (string) $file['contenthash']; |
185
|
|
|
$subDir = $precomputedSubdir ?: substr($contenthash, 0, 2); |
186
|
|
|
$exportSubDir = $filesDir.'/'.$subDir; |
187
|
|
|
|
188
|
|
|
if (!is_dir($exportSubDir)) { |
189
|
|
|
mkdir($exportSubDir, api_get_permissions_for_new_directories(), true); |
190
|
|
|
} |
191
|
|
|
|
192
|
|
|
$destinationFile = $exportSubDir.'/'.$contenthash; |
193
|
|
|
|
194
|
|
|
$filePath = $file['abs_path'] ?? null; |
195
|
|
|
if (!$filePath) { |
196
|
|
|
$filePath = $this->course->path.$file['documentpath']; |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
if (is_file($filePath)) { |
200
|
|
|
if (!is_file($destinationFile)) { |
201
|
|
|
copy($filePath, $destinationFile); |
202
|
|
|
} |
203
|
|
|
} else { |
204
|
|
|
throw new Exception("Source file not found: {$filePath}"); |
205
|
|
|
} |
206
|
|
|
} |
207
|
|
|
|
208
|
|
|
/** |
209
|
|
|
* Create the files.xml with the provided file data. |
210
|
|
|
* |
211
|
|
|
* @param array<string,mixed> $filesData |
212
|
|
|
*/ |
213
|
|
|
private function createFilesXml(array $filesData, string $destinationDir): void |
214
|
|
|
{ |
215
|
|
|
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; |
216
|
|
|
$xmlContent .= '<files>'.PHP_EOL; |
217
|
|
|
|
218
|
|
|
foreach (($filesData['files'] ?? []) as $file) { |
219
|
|
|
$xmlContent .= $this->createFileXmlEntry($file); |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
$xmlContent .= '</files>'.PHP_EOL; |
223
|
|
|
file_put_contents($destinationDir.'/files.xml', $xmlContent); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
/** |
227
|
|
|
* Create an XML entry for a file. |
228
|
|
|
* |
229
|
|
|
* @param array<string,mixed> $file |
230
|
|
|
*/ |
231
|
|
|
private function createFileXmlEntry(array $file): string |
232
|
|
|
{ |
233
|
|
|
// itemid is forced to 0 in V1 files.xml for consistency with restore |
234
|
|
|
return ' <file id="'.(int) $file['id'].'">'.PHP_EOL. |
235
|
|
|
' <contenthash>'.htmlspecialchars((string) $file['contenthash']).'</contenthash>'.PHP_EOL. |
236
|
|
|
' <contextid>'.(int) $file['contextid'].'</contextid>'.PHP_EOL. |
237
|
|
|
' <component>'.htmlspecialchars((string) $file['component']).'</component>'.PHP_EOL. |
238
|
|
|
' <filearea>'.htmlspecialchars((string) $file['filearea']).'</filearea>'.PHP_EOL. |
239
|
|
|
' <itemid>0</itemid>'.PHP_EOL. |
240
|
|
|
' <filepath>'.htmlspecialchars((string) $file['filepath']).'</filepath>'.PHP_EOL. |
241
|
|
|
' <filename>'.htmlspecialchars((string) $file['filename']).'</filename>'.PHP_EOL. |
242
|
|
|
' <userid>'.(int) $file['userid'].'</userid>'.PHP_EOL. |
243
|
|
|
' <filesize>'.(int) $file['filesize'].'</filesize>'.PHP_EOL. |
244
|
|
|
' <mimetype>'.htmlspecialchars((string) $file['mimetype']).'</mimetype>'.PHP_EOL. |
245
|
|
|
' <status>'.(int) $file['status'].'</status>'.PHP_EOL. |
246
|
|
|
' <timecreated>'.(int) $file['timecreated'].'</timecreated>'.PHP_EOL. |
247
|
|
|
' <timemodified>'.(int) $file['timemodified'].'</timemodified>'.PHP_EOL. |
248
|
|
|
' <source>'.htmlspecialchars((string) $file['source']).'</source>'.PHP_EOL. |
249
|
|
|
' <author>'.htmlspecialchars((string) $file['author']).'</author>'.PHP_EOL. |
250
|
|
|
' <license>'.htmlspecialchars((string) $file['license']).'</license>'.PHP_EOL. |
251
|
|
|
' <sortorder>0</sortorder>'.PHP_EOL. |
252
|
|
|
' <repositorytype>$@NULL@$</repositorytype>'.PHP_EOL. |
253
|
|
|
' <repositoryid>$@NULL@$</repositoryid>'.PHP_EOL. |
254
|
|
|
' <reference>$@NULL@$</reference>'.PHP_EOL. |
255
|
|
|
' </file>'.PHP_EOL; |
256
|
|
|
} |
257
|
|
|
|
258
|
|
|
/** |
259
|
|
|
* Process a document or folder and add its data to the files array. |
260
|
|
|
* |
261
|
|
|
* @param array<string,mixed> $filesData |
262
|
|
|
*/ |
263
|
|
|
private function processDocument(array $filesData, object $document): array |
264
|
|
|
{ |
265
|
|
|
// Skip files already embedded/handled by PageExport |
266
|
|
|
if ( |
267
|
|
|
($document->file_type ?? null) === 'file' |
268
|
|
|
&& isset($this->course->used_page_doc_ids) |
269
|
|
|
&& \in_array($document->source_id, (array) $this->course->used_page_doc_ids, true) |
270
|
|
|
) { |
271
|
|
|
return $filesData; |
272
|
|
|
} |
273
|
|
|
|
274
|
|
|
// Skip top-level HTML documents that are exported as Page |
275
|
|
|
if ( |
276
|
|
|
($document->file_type ?? null) === 'file' |
277
|
|
|
&& 'html' === pathinfo($document->path, PATHINFO_EXTENSION) |
278
|
|
|
&& 1 === substr_count($document->path, '/') |
279
|
|
|
) { |
280
|
|
|
return $filesData; |
281
|
|
|
} |
282
|
|
|
|
283
|
|
|
if (($document->file_type ?? null) === 'file') { |
284
|
|
|
$extension = strtolower((string) pathinfo($document->path, PATHINFO_EXTENSION)); |
285
|
|
|
if (!\in_array($extension, ['html', 'htm'], true)) { |
286
|
|
|
$fileData = $this->getFileData($document); |
287
|
|
|
$fileData['filepath'] = '/Documents/'; |
288
|
|
|
$fileData['contextid'] = 0; |
289
|
|
|
$fileData['component'] = 'mod_folder'; |
290
|
|
|
$filesData['files'][] = $fileData; |
291
|
|
|
} |
292
|
|
|
} elseif (($document->file_type ?? null) === 'folder') { |
293
|
|
|
$docRepo = Container::getDocumentRepository(); |
294
|
|
|
$folderFiles = $docRepo->listFilesByParentIid((int) $document->source_id); |
295
|
|
|
|
296
|
|
|
foreach ($folderFiles as $file) { |
297
|
|
|
$filesData['files'][] = $this->getFolderFileData( |
298
|
|
|
$file, |
299
|
|
|
(int) $document->source_id, |
300
|
|
|
'/Documents/'.\dirname($file['path']).'/' |
301
|
|
|
); |
302
|
|
|
} |
303
|
|
|
} |
304
|
|
|
|
305
|
|
|
return $filesData; |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
/** |
309
|
|
|
* Get file data for a single document. |
310
|
|
|
* |
311
|
|
|
* @return array<string,mixed> |
312
|
|
|
*/ |
313
|
|
|
private function getFileData(object $document): array |
314
|
|
|
{ |
315
|
|
|
$adminData = MoodleExport::getAdminUserData(); |
316
|
|
|
$adminId = $adminData['id'] ?? 0; |
317
|
|
|
|
318
|
|
|
$contenthash = hash('sha1', basename($document->path)); |
319
|
|
|
$mimetype = $this->getMimeType($document->path); |
320
|
|
|
|
321
|
|
|
// Try to resolve absolute path for single file documents |
322
|
|
|
$absPath = null; |
323
|
|
|
if (isset($document->source_id)) { |
324
|
|
|
$repo = Container::getDocumentRepository(); |
325
|
|
|
$doc = $repo->findOneBy(['iid' => (int) $document->source_id]); |
326
|
|
|
if ($doc instanceof CDocument) { |
327
|
|
|
$absPath = $repo->getAbsolutePathForDocument($doc); |
328
|
|
|
} |
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
return [ |
332
|
|
|
'id' => (int) $document->source_id, |
333
|
|
|
'contenthash' => $contenthash, |
334
|
|
|
'contextid' => (int) $document->source_id, |
335
|
|
|
'component' => 'mod_resource', |
336
|
|
|
'filearea' => 'content', |
337
|
|
|
'itemid' => (int) $document->source_id, |
338
|
|
|
'filepath' => '/', |
339
|
|
|
'documentpath' => (string) $document->path, |
340
|
|
|
'filename' => basename($document->path), |
341
|
|
|
'userid' => $adminId, |
342
|
|
|
'filesize' => (int) $document->size, |
343
|
|
|
'mimetype' => $mimetype, |
344
|
|
|
'status' => 0, |
345
|
|
|
'timecreated' => time() - 3600, |
346
|
|
|
'timemodified' => time(), |
347
|
|
|
'source' => (string) $document->title, |
348
|
|
|
'author' => 'Unknown', |
349
|
|
|
'license' => 'allrightsreserved', |
350
|
|
|
// New: absolute path for reliable copy |
351
|
|
|
'abs_path' => $absPath, |
352
|
|
|
]; |
353
|
|
|
} |
354
|
|
|
|
355
|
|
|
/** |
356
|
|
|
* Get file data for files inside a folder. |
357
|
|
|
* |
358
|
|
|
* @param array<string,mixed> $file |
359
|
|
|
* |
360
|
|
|
* @return array<string,mixed> |
361
|
|
|
*/ |
362
|
|
|
private function getFolderFileData(array $file, int $sourceId, string $parentPath = '/Documents/'): array |
363
|
|
|
{ |
364
|
|
|
$adminData = MoodleExport::getAdminUserData(); |
365
|
|
|
$adminId = $adminData['id'] ?? 0; |
366
|
|
|
|
367
|
|
|
$contenthash = hash('sha1', basename($file['path'])); |
368
|
|
|
$mimetype = $this->getMimeType($file['path']); |
369
|
|
|
$filename = basename($file['path']); |
370
|
|
|
$filepath = $this->ensureTrailingSlash($parentPath); |
371
|
|
|
|
372
|
|
|
return [ |
373
|
|
|
'id' => (int) $file['id'], |
374
|
|
|
'contenthash' => $contenthash, |
375
|
|
|
'contextid' => $sourceId, |
376
|
|
|
'component' => 'mod_folder', |
377
|
|
|
'filearea' => 'content', |
378
|
|
|
'itemid' => (int) $file['id'], |
379
|
|
|
'filepath' => $filepath, |
380
|
|
|
'documentpath' => 'document/'.$file['path'], |
381
|
|
|
'filename' => $filename, |
382
|
|
|
'userid' => $adminId, |
383
|
|
|
'filesize' => (int) $file['size'], |
384
|
|
|
'mimetype' => $mimetype, |
385
|
|
|
'status' => 0, |
386
|
|
|
'timecreated' => time() - 3600, |
387
|
|
|
'timemodified' => time(), |
388
|
|
|
'source' => (string) $file['title'], |
389
|
|
|
'author' => 'Unknown', |
390
|
|
|
'license' => 'allrightsreserved', |
391
|
|
|
'abs_path' => $file['abs_path'] ?? null, |
392
|
|
|
]; |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
/** |
396
|
|
|
* Ensure the directory path has a trailing slash. |
397
|
|
|
*/ |
398
|
|
|
private function ensureTrailingSlash(string $path): string |
399
|
|
|
{ |
400
|
|
|
if ('' === $path || '.' === $path || '/' === $path) { |
401
|
|
|
return '/'; |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
$path = (string) preg_replace('/\/+/', '/', $path); |
405
|
|
|
|
406
|
|
|
return rtrim($path, '/').'/'; |
407
|
|
|
} |
408
|
|
|
|
409
|
|
|
/** |
410
|
|
|
* Get MIME type based on the file extension. |
411
|
|
|
*/ |
412
|
|
|
public function getMimeType(string $filePath): string |
413
|
|
|
{ |
414
|
|
|
$extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION)); |
415
|
|
|
$mimeTypes = $this->getMimeTypes(); |
416
|
|
|
|
417
|
|
|
return $mimeTypes[$extension] ?? 'application/octet-stream'; |
418
|
|
|
} |
419
|
|
|
|
420
|
|
|
/** |
421
|
|
|
* Get an array of file extensions and their corresponding MIME types. |
422
|
|
|
* |
423
|
|
|
* @return array<string,string> |
424
|
|
|
*/ |
425
|
|
|
private function getMimeTypes(): array |
426
|
|
|
{ |
427
|
|
|
return [ |
428
|
|
|
'pdf' => 'application/pdf', |
429
|
|
|
'jpg' => 'image/jpeg', |
430
|
|
|
'jpeg' => 'image/jpeg', |
431
|
|
|
'png' => 'image/png', |
432
|
|
|
'gif' => 'image/gif', |
433
|
|
|
'html' => 'text/html', |
434
|
|
|
'htm' => 'text/html', |
435
|
|
|
'txt' => 'text/plain', |
436
|
|
|
'doc' => 'application/msword', |
437
|
|
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
438
|
|
|
'xls' => 'application/vnd.ms-excel', |
439
|
|
|
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
440
|
|
|
'ppt' => 'application/vnd.ms-powerpoint', |
441
|
|
|
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', |
442
|
|
|
'zip' => 'application/zip', |
443
|
|
|
'rar' => 'application/x-rar-compressed', |
444
|
|
|
]; |
445
|
|
|
} |
446
|
|
|
} |
447
|
|
|
|
This function has been deprecated. The supplier of the function has supplied an explanatory message.
The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.