Total Complexity | 53 |
Total Lines | 423 |
Duplicated Lines | 0 % |
Changes | 1 | ||
Bugs | 0 | Features | 0 |
Complex classes like FileExport often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use FileExport, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
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 |
||
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.