diff --git a/mod/data/classes/local/exporter.php b/mod/data/classes/local/exporter.php index 050ee4c963a..98abb292a9a 100644 --- a/mod/data/classes/local/exporter.php +++ b/mod/data/classes/local/exporter.php @@ -17,10 +17,11 @@ namespace mod_data\local; use file_serving_exception; +use moodle_exception; use zip_archive; /** - * Exporter class for exporting data. + * Exporter class for exporting data and - if needed - files as well in a zip archive. * * @package mod_data * @copyright 2023 ISB Bayern @@ -41,15 +42,30 @@ abstract class exporter { /** @var string Name of the export file name without extension. */ protected string $exportfilename; + /** @var zip_archive The zip archive object we store all the files in, if we need to export files as well. */ + private zip_archive $ziparchive; + + /** @var bool Tracks the state if the zip archive already has been closed. */ + private bool $ziparchiveclosed; + + /** @var string full path of the zip archive. */ + private string $zipfilepath; + + /** @var array Array to store all filenames in the zip archive for export. */ + private array $filenamesinzip; + /** * Creates an exporter object. * - * This object can be used to export data to different formats. + * This object can be used to export data to different formats including files. If files are added, + * everything will be bundled up in a zip archive. */ public function __construct() { $this->currentrow = 0; $this->exportdata = []; $this->exportfilename = 'Exportfile'; + $this->filenamesinzip = []; + $this->ziparchiveclosed = true; } /** @@ -124,6 +140,28 @@ abstract class exporter { return count($this->exportdata) - 1; } + /** + * Use this method to add a file which should be exported to the exporter. + * + * @param string $filename the name of the file which should be added + * @param string $filecontent the content of the file as a string + * @param string $zipsubdir the subdirectory in the zip archive. Defaults to 'files/'. + * @return void + * @throws moodle_exception if there is an error adding the file to the zip archive + */ + public function add_file_from_string(string $filename, string $filecontent, string $zipsubdir = 'files/'): void { + if (empty($this->filenamesinzip)) { + // No files added yet, so we need to create a zip archive. + $this->create_zip_archive(); + } + if (!str_ends_with($zipsubdir, '/')) { + $zipsubdir .= '/'; + } + $zipfilename = $zipsubdir . $filename; + $this->filenamesinzip[] = $zipfilename; + $this->ziparchive->add_file_from_string($zipfilename, $filecontent); + } + /** * Sends the generated export file. * @@ -132,6 +170,8 @@ abstract class exporter { * @param bool $sendtouser true if the file should be sent directly to the user, if false the file content will be returned * as string * @return string|null file content as string if $sendtouser is true + * @throws moodle_exception if there is an issue adding the data file + * @throws file_serving_exception if the file could not be served properly */ public function send_file(bool $sendtouser = true): null|string { if (empty($this->filenamesinzip)) { @@ -144,6 +184,85 @@ abstract class exporter { return $this->get_data_file_content(); } } - return null; + $this->add_file_from_string($this->exportfilename . '.' . $this->get_export_data_file_extension(), + $this->get_data_file_content(), '/'); + $this->finish_zip_archive(); + + if ($this->ziparchiveclosed) { + if ($sendtouser) { + send_file($this->zipfilepath, $this->exportfilename . '.zip', null, 0, false, true); + return null; + } else { + return file_get_contents($this->zipfilepath); + } + } else { + throw new file_serving_exception('Could not serve zip file, it could not be closed properly.'); + } + } + + /** + * Checks if a file with the given name has already been added to the file export bundle. + * + * Care: Filenames are compared to all files in the specified zip subdirectory which + * defaults to 'files/'. + * + * @param string $filename the filename containing the zip path of the file to check + * @param string $zipsubdir The subdirectory in which the filename should be looked for, + * defaults to 'files/' + * @return bool true if file with the given name already exists, false otherwise + */ + public function file_exists(string $filename, string $zipsubdir = 'files/'): bool { + if (!str_ends_with($zipsubdir, '/')) { + $zipsubdir .= '/'; + } + return in_array($zipsubdir . $filename, $this->filenamesinzip, true); + } + + /** + * Creates a unique filename based on the given filename. + * + * This method adds "_1", "_2", ... to the given file name until the newly generated filename + * is not equal to any of the already saved ones in the export file bundle. + * + * @param string $filename the filename based on which a unique filename should be generated + * @return string the unique filename + */ + public function create_unique_filename(string $filename): string { + if (!$this->file_exists($filename)) { + return $filename; + } + $i = 1; + + while ($this->file_exists($filename)) { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $filenamewithoutextension = substr($filename, 0, + strlen($filename) - strlen($extension) - 1); + $filename = $filenamewithoutextension . '_' . $i . '.' . $extension; + $i++; + } + return $filename; + } + + /** + * Prepares the zip archive. + * + * @return void + */ + private function create_zip_archive(): void { + $tmpdir = make_request_directory(); + $this->zipfilepath = $tmpdir . '/' . $this->exportfilename . '.zip'; + $this->ziparchive = new zip_archive(); + $this->ziparchiveclosed = !$this->ziparchive->open($this->zipfilepath); + } + + /** + * Closes the zip archive. + * + * @return void + */ + private function finish_zip_archive(): void { + if (!$this->ziparchiveclosed) { + $this->ziparchiveclosed = $this->ziparchive->close(); + } } } diff --git a/mod/data/classes/local/exporter_utils.php b/mod/data/classes/local/exporter_utils.php index 63d74ae85e6..97df8370104 100644 --- a/mod/data/classes/local/exporter_utils.php +++ b/mod/data/classes/local/exporter_utils.php @@ -46,6 +46,7 @@ class exporter_utils { * @param bool $time whether to include time created/modified * @param bool $approval whether to include approval status * @param bool $tags whether to include tags + * @param bool $includefiles whether files should be exported as well * @return void * @throws coding_exception * @throws dml_exception @@ -53,7 +54,7 @@ class exporter_utils { */ public static function data_exportdata(int $dataid, array $fields, array $selectedfields, exporter $exporter, int $currentgroup = 0, context $context = null, bool $userdetails = false, bool $time = false, bool $approval = false, - bool $tags = false): void { + bool $tags = false, bool $includefiles = true): void { global $DB; if (is_null($context)) { @@ -108,6 +109,15 @@ class exporter_utils { $contents = ''; if (isset($content[$field->field->id])) { $contents = $field->export_text_value($content[$field->field->id]); + if (!empty($contents) && $field->file_export_supported() && $includefiles + && !is_null($field->export_file_value($record))) { + // For exporting overwrite the content of the column with a unique + // filename, even it is not exactly the name of the file in the + // mod_data instance content. But it's more important to match the name + // of the exported file. + $contents = $exporter->create_unique_filename($contents); + $exporter->add_file_from_string($contents, $field->export_file_value($record)); + } } // Just be double sure. $contents = !empty($contents) ? $contents : ''; diff --git a/mod/data/export.php b/mod/data/export.php index 1fbc9d59f76..8e2566a4355 100644 --- a/mod/data/export.php +++ b/mod/data/export.php @@ -109,8 +109,9 @@ if ($mform->is_cancelled()) { . 'Only "csv" and "ods" are currently supported.'); } + $includefiles = !empty($formdata['includefiles']); \mod_data\local\exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context, - $exportuser, $exporttime, $exportapproval, $tags); + $exportuser, $exporttime, $exportapproval, $tags, $includefiles); $count = $exporter->get_records_count(); $filename = clean_filename("{$data->name}-{$count}_record"); if ($count > 1) { diff --git a/mod/data/export_form.php b/mod/data/export_form.php index 75f27f4b5d2..f96dc1569c3 100644 --- a/mod/data/export_form.php +++ b/mod/data/export_form.php @@ -112,6 +112,8 @@ class mod_data_export_form extends moodleform { $exportoptions[] = $mform->createElement('checkbox', 'exportapproval', get_string('includeapproval', 'data'), '', $optionattrs); } + $exportoptions[] = $mform->createElement('checkbox', 'includefiles', get_string('includefiles', 'data'), '', $optionattrs); + $mform->setDefault('includefiles', 1); $mform->addGroup($exportoptions, 'exportoptions', get_string('selectexportoptions', 'data'), ['
'], false); $this->add_action_buttons(true, get_string('exportentries', 'data')); diff --git a/mod/data/field/file/field.class.php b/mod/data/field/file/field.class.php index 16fe7ce15d8..79d287457a2 100644 --- a/mod/data/field/file/field.class.php +++ b/mod/data/field/file/field.class.php @@ -218,8 +218,43 @@ class data_field_file extends data_field_base { $DB->update_record('data_content', $content); } - function text_export_supported() { - return false; + /** + * File field supports export of text. The text being exported is the filename of the stored file. + * + * @return bool true + */ + public function text_export_supported() { + return true; + } + + /** + * Here we export the text value of a file field which is the filename of the exported file. + * + * @param stdClass $record the record which is being exported + * @return string the value which will be stored in the exported file for this field + */ + public function export_text_value(stdClass $record): string { + return !empty($record->content) ? $record->content : ''; + } + + /** + * Specifies that this field type supports the export of files. + * + * @return bool true which means that file export is being supported by this field type + */ + public function file_export_supported(): bool { + return true; + } + + /** + * Exports the file content for file export. + * + * @param stdClass $record the data content record the file belongs to + * @return null|string The file content of the stored file or null if no file should be exported for this record + */ + public function export_file_value(stdClass $record): null|string { + $file = $this->get_file($record->id); + return $file ? $file->get_content() : null; } function file_ok($path) { diff --git a/mod/data/field/picture/field.class.php b/mod/data/field/picture/field.class.php index 877f0fb9c01..47bb0af5c4f 100644 --- a/mod/data/field/picture/field.class.php +++ b/mod/data/field/picture/field.class.php @@ -348,8 +348,43 @@ class data_field_picture extends data_field_base { } } - function text_export_supported() { - return false; + /** + * Picture field supports export of text. The text being exported is the filename of the stored picture. + * + * @return bool true + */ + public function text_export_supported() { + return true; + } + + /** + * Here we export the text value of a picture field which is the filename of the exported picture. + * + * @param stdClass $record the record which is being exported + * @return string the value which will be stored in the exported file for this field + */ + public function export_text_value(stdClass $record): string { + return !empty($record->content) ? $record->content : ''; + } + + /** + * Specifies that this field type supports the export of files. + * + * @return bool true which means that file export is being supported by this field type + */ + public function file_export_supported(): bool { + return true; + } + + /** + * Exports the file content for file export. + * + * @param stdClass $record the data content record the file belongs to + * @return null|string The file content of the stored file or null if no file should be exported for this record + */ + public function export_file_value(stdClass $record): null|string { + $file = $this->get_file($record->id); + return $file ? $file->get_content() : null; } function file_ok($path) { diff --git a/mod/data/lang/en/data.php b/mod/data/lang/en/data.php index bfebb9f7585..ecd6770074f 100644 --- a/mod/data/lang/en/data.php +++ b/mod/data/lang/en/data.php @@ -228,6 +228,7 @@ $string['importapreset'] = 'Import a preset'; $string['importsuccess'] = 'Preset applied.'; $string['importpresetmissingcapability'] = 'You don\'t have permission to import a preset.'; $string['includeapproval'] = 'Include approval status'; +$string['includefiles'] = 'Include files in export'; $string['includetags'] = 'Include tags'; $string['includetime'] = 'Include time added/modified'; $string['includeuserdetails'] = 'Include user details'; diff --git a/mod/data/lib.php b/mod/data/lib.php index da92029ab38..8b31fe41dc5 100644 --- a/mod/data/lib.php +++ b/mod/data/lib.php @@ -640,16 +640,39 @@ class data_field_base { // Base class for Database Field Types (see field/*/ } /** - * Per default, return the record's text value only from the "content" field. - * Override this in fields class if necesarry. + * Per default, it is assumed that fields do not support file exporting. Override this (return true) + * on fields supporting file export. You will also have to implement export_file_value(). * - * @param string $record + * @return bool true if field will export a file, false otherwise + */ + public function file_export_supported(): bool { + return false; + } + + /** + * Per default, does not return a file (just null). + * Override this in fields class, if you want your field to export a file content. + * In case you are exporting a file value, export_text_value() should return the corresponding file name. + * + * @param stdClass $record + * @return null|string the file content as string or null, if no file content is being provided + */ + public function export_file_value(stdClass $record): null|string { + return null; + } + + /** + * Per default, return the record's text value only from the "content" field. + * Override this in fields class if necessary. + * + * @param stdClass $record * @return string */ - function export_text_value($record) { + public function export_text_value(stdClass $record) { if ($this->text_export_supported()) { return $record->content; } + return ''; } /** diff --git a/mod/data/tests/export_test.php b/mod/data/tests/export_test.php index 20efcc29110..ef4c172f697 100644 --- a/mod/data/tests/export_test.php +++ b/mod/data/tests/export_test.php @@ -63,8 +63,23 @@ class export_test extends \advanced_testcase { $fieldrecord->type = 'text'; $textfield = $generator->create_field($fieldrecord, $data); + $fieldrecord->name = 'filefield1'; + $fieldrecord->type = 'file'; + $filefield1 = $generator->create_field($fieldrecord, $data); + + $fieldrecord->name = 'filefield2'; + $fieldrecord->type = 'file'; + $filefield2 = $generator->create_field($fieldrecord, $data); + + $fieldrecord->name = 'picturefield'; + $fieldrecord->type = 'picture'; + $picturefield = $generator->create_field($fieldrecord, $data); + $contents[$numberfield->field->id] = '3'; $contents[$textfield->field->id] = 'a simple text'; + $contents[$filefield1->field->id] = 'samplefile.png'; + $contents[$filefield2->field->id] = 'samplefile.png'; + $contents[$picturefield->field->id] = ['picturefile.png', 'this picture shows something']; $generator->create_entry($data, $contents); return [ @@ -105,10 +120,49 @@ class export_test extends \advanced_testcase { $exporttime = false; $exportapproval = false; $tags = false; - + // We first test the export without exporting files. + // This means file and picture fields will be exported, but only as text (which is the filename), + // so we will receive a csv export file. + $includefiles = false; exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context, - $exportuser, $exporttime, $exportapproval, $tags); - $this->assertEquals(file_get_contents(__DIR__ . '/fixtures/test_data_export.csv'), + $exportuser, $exporttime, $exportapproval, $tags, $includefiles); + $this->assertEquals(file_get_contents(__DIR__ . '/fixtures/test_data_export_without_files.csv'), $exporter->send_file(false)); + + // We now test the export including files. This will generate a zip archive. + $includefiles = true; + $exporter = new csv_exporter(); + $exporter->set_export_file_name('testexportfile'); + exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context, + $exportuser, $exporttime, $exportapproval, $tags, $includefiles); + // We now write the zip archive temporary to disc to be able to parse it and assert it has the correct structure. + $tmpdir = make_request_directory(); + file_put_contents($tmpdir . '/testexportarchive.zip', $exporter->send_file(false)); + $ziparchive = new \zip_archive(); + $ziparchive->open($tmpdir . '/testexportarchive.zip'); + $expectedfilecontents = [ + // The test generator for mod_data uses a copy of pix/monologo.png as sample file content for the file stored in a + // file and picture field. + // So we expect that this file has to have the same content as monologo.png. + // Also, the default value for the subdirectory in the zip archive containing the files is 'files/'. + 'files/samplefile.png' => 'mod/data/pix/monologo.png', + 'files/samplefile_1.png' => 'mod/data/pix/monologo.png', + 'files/picturefile.png' => 'mod/data/pix/monologo.png', + // By checking that the content of the exported csv is identical to the fixture file it is verified + // that the filenames in the csv file correspond to the names of the exported file. + // It also verifies that files with identical file names in different fields (or records) will be numbered + // automatically (samplefile.png, samplefile_1.png, ...). + 'testexportfile.csv' => __DIR__ . '/fixtures/test_data_export_with_files.csv' + ]; + for ($i = 0; $i < $ziparchive->count(); $i++) { + // We here iterate over all files in the zip archive and check if their content is identical to the files + // in the $expectedfilecontents array. + $filestream = $ziparchive->get_stream($i); + $fileinfo = $ziparchive->get_info($i); + $filecontent = fread($filestream, $fileinfo->size); + $this->assertEquals(file_get_contents($expectedfilecontents[$fileinfo->pathname]), $filecontent); + fclose($filestream); + } + $ziparchive->close(); } } diff --git a/mod/data/tests/fixtures/test_data_export.csv b/mod/data/tests/fixtures/test_data_export.csv deleted file mode 100644 index 7ffd4341f9f..00000000000 --- a/mod/data/tests/fixtures/test_data_export.csv +++ /dev/null @@ -1,2 +0,0 @@ -numberfield,textfield -3,"a simple text" diff --git a/mod/data/tests/fixtures/test_data_export_with_files.csv b/mod/data/tests/fixtures/test_data_export_with_files.csv new file mode 100644 index 00000000000..c6f128833a8 --- /dev/null +++ b/mod/data/tests/fixtures/test_data_export_with_files.csv @@ -0,0 +1,2 @@ +numberfield,textfield,filefield1,filefield2,picturefield +3,"a simple text",samplefile.png,samplefile_1.png,picturefile.png diff --git a/mod/data/tests/fixtures/test_data_export_without_files.csv b/mod/data/tests/fixtures/test_data_export_without_files.csv new file mode 100644 index 00000000000..2191fe7d9ca --- /dev/null +++ b/mod/data/tests/fixtures/test_data_export_without_files.csv @@ -0,0 +1,2 @@ +numberfield,textfield,filefield1,filefield2,picturefield +3,"a simple text",samplefile.png,samplefile.png,picturefile.png diff --git a/mod/data/upgrade.txt b/mod/data/upgrade.txt index a7df292aaf8..7b6a3f05048 100644 --- a/mod/data/upgrade.txt +++ b/mod/data/upgrade.txt @@ -10,6 +10,12 @@ information provided here is intended especially for developers. * Function data_import_csv() has been deprecated and moved to deprecatedlib due to a bigger rework of the way data is being imported. This is now being done by new importer class \mod_data\local\mod_data_csv_importer inheriting from new classes \mod_data\local\csv_importer and \mod_data\local\importer. +* Field base class now has two new methods file_export_supported() and export_file_value(). The method + file_export_supported() can be overwritten to declare that a field type can/wants to export a file. In this case this + field type will have to implement the method export_file_value() returning this file for exporting. Also: This field + type will have to export the name of the file by overwriting text_export_supported() to return true and make the + method export_text_value() return the name of the file. +* The field types file and picture now are able to export the file/picture. == 4.2 == * The field base class now has a method validate(). Overwrite it in the field type to provide validation of field type's