diff --git a/dataformat/pdf/classes/writer.php b/dataformat/pdf/classes/writer.php index 1f204d83e61..1fce4399d16 100644 --- a/dataformat/pdf/classes/writer.php +++ b/dataformat/pdf/classes/writer.php @@ -72,6 +72,13 @@ class writer extends \core\dataformat\base { public function send_http_headers() { } + /** + * Start output to file, note that the actual writing of the file is done in {@see close_output_to_file()} + */ + public function start_output_to_file(): void { + $this->start_output(); + } + public function start_output() { $this->pdf->AddPage('L'); } @@ -126,6 +133,17 @@ class writer extends \core\dataformat\base { $this->pdf->Output($filename, 'D'); } + /** + * Write data to disk + * + * @return bool + */ + public function close_output_to_file(): bool { + $this->pdf->Output($this->filepath, 'F'); + + return true; + } + /** * Prints the heading row. */ diff --git a/dataformat/upgrade.txt b/dataformat/upgrade.txt index 5868991c5ae..9ec18bb6f19 100644 --- a/dataformat/upgrade.txt +++ b/dataformat/upgrade.txt @@ -1,8 +1,13 @@ This files describes API changes in /dataformat/ download system, information provided here is intended especially for developers. -=== 3.4 === +=== 3.9 === +* The following methods have been added to the base dataformat class to allow instances to export to a local + file. They can be overridden in extending classes to define how files should be created: + - start_output_to_file() + - close_output_to_file() +=== 3.4 === * In order to allow multiple sheets in an exported file the functions write_header() and write_footer() have been removed from core dataformat plugins and have been replaced. - write_header() has been replaced with the two functions start_output() and start_sheet(). diff --git a/lib/classes/dataformat/base.php b/lib/classes/dataformat/base.php index b9682b92a84..a0dc3686fa6 100644 --- a/lib/classes/dataformat/base.php +++ b/lib/classes/dataformat/base.php @@ -25,6 +25,8 @@ namespace core\dataformat; +use coding_exception; + /** * Base class for dataformat. * @@ -44,6 +46,9 @@ abstract class base { /** @var $filename */ protected $filename = ''; + /** @var string The location to store the output content */ + protected $filepath = ''; + /** * Get the file extension * @@ -62,6 +67,24 @@ abstract class base { $this->filename = $filename; } + /** + * Set file path when writing to file + * + * @param string $filepath + * @throws coding_exception + */ + public function set_filepath(string $filepath): void { + $filedir = dirname($filepath); + if (!is_writable($filedir)) { + throw new coding_exception('File path is not writable'); + } + + $this->filepath = $filepath; + + // Some dataformat writers may expect filename to be set too. + $this->set_filename(pathinfo($this->filepath, PATHINFO_FILENAME)); + } + /** * Set the title of the worksheet inside a spreadsheet * @@ -95,6 +118,17 @@ abstract class base { header("Content-Disposition: attachment; filename=\"$filename\""); } + /** + * Set the dataformat to be output to current file. Calling code must call {@see base::close_output_to_file()} when finished + */ + public function start_output_to_file(): void { + // Raise memory limit to ensure we can store the entire content. Start collecting output. + raise_memory_limit(MEMORY_EXTRA); + + ob_start(); + $this->start_output(); + } + /** * Write the start of the file. */ @@ -134,4 +168,18 @@ abstract class base { public function close_output() { // Override me if needed. } + + /** + * Write the data to disk. Calling code should have previously called {@see base::start_output_to_file()} + * + * @return bool Whether the write succeeded + */ + public function close_output_to_file(): bool { + $this->close_output(); + + $filecontent = ob_get_contents(); + ob_end_clean(); + + return file_put_contents($this->filepath, $filecontent) !== false; + } } diff --git a/lib/classes/dataformat/spout_base.php b/lib/classes/dataformat/spout_base.php index 55947ef67e7..02a827f60f9 100644 --- a/lib/classes/dataformat/spout_base.php +++ b/lib/classes/dataformat/spout_base.php @@ -66,6 +66,23 @@ abstract class spout_base extends \core\dataformat\base { $this->renamecurrentsheet = true; } + /** + * Set the dataformat to be output to current file + */ + public function start_output_to_file(): void { + $this->writer = \Box\Spout\Writer\Common\Creator\WriterEntityFactory::createWriter($this->spouttype); + if (method_exists($this->writer, 'setTempFolder')) { + $this->writer->setTempFolder(make_request_directory()); + } + + $this->writer->openToFile($this->filepath); + + // By default one sheet is always created, but we want to rename it when we call start_sheet(). + $this->renamecurrentsheet = true; + + $this->start_output(); + } + /** * Set the title of the worksheet inside a spreadsheet * @@ -114,4 +131,15 @@ abstract class spout_base extends \core\dataformat\base { $this->writer->close(); $this->writer = null; } + + /** + * Write data to disk + * + * @return bool + */ + public function close_output_to_file(): bool { + $this->close_output(); + + return true; + } } diff --git a/lib/dataformatlib.php b/lib/dataformatlib.php index 5842e53673e..dac5c4a8b3d 100644 --- a/lib/dataformatlib.php +++ b/lib/dataformatlib.php @@ -33,8 +33,8 @@ * @param string $dataformat A dataformat name * @param array $columns An ordered map of column keys and labels * @param Iterator $iterator An iterator over the records, usually a RecordSet - * @param function $callback An option function applied to each record before writing - * @param mixed $extra An optional value which is passed into the callback function + * @param callable $callback An option function applied to each record before writing + * @throws coding_exception */ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $callback = null) { @@ -46,10 +46,9 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca if (!class_exists($classname)) { throw new coding_exception("Unable to locate dataformat/$dataformat/classes/writer.php"); } - $format = new $classname; // The data format export could take a while to generate... - set_time_limit(0); + core_php_time_limit::raise(); // Close the session so that the users other tabs in the same session are not blocked. \core\session\manager::write_close(); @@ -57,17 +56,22 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca // If this file was requested from a form, then mark download as complete (before sending headers). \core_form\util::form_download_complete(); + /** @var \core\dataformat\base $format */ + $format = new $classname; + $format->set_filename($filename); $format->send_http_headers(); + // This exists to support all dataformats - see MDL-56046. - if (method_exists($format, 'write_header')) { - error_log('The function write_header() does not support multiple sheets. In order to support multiple sheets you ' . - 'must implement start_output() and start_sheet() and remove write_header() in your dataformat.'); + if (!method_exists($format, 'start_output') && method_exists($format, 'write_header')) { + debugging('The function write_header() does not support multiple sheets. In order to support multiple sheets you ' . + 'must implement start_output() and start_sheet() and remove write_header() in your dataformat.', DEBUG_DEVELOPER); $format->write_header($columns); } else { $format->start_output(); $format->start_sheet($columns); } + $c = 0; foreach ($iterator as $row) { if ($callback) { @@ -78,10 +82,11 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca } $format->write_record($row, $c++); } + // This exists to support all dataformats - see MDL-56046. - if (method_exists($format, 'write_footer')) { - error_log('The function write_footer() does not support multiple sheets. In order to support multiple sheets you ' . - 'must implement close_sheet() and close_output() and remove write_footer() in your dataformat.'); + if (!method_exists($format, 'close_output') && method_exists($format, 'write_footer')) { + debugging('The function write_footer() does not support multiple sheets. In order to support multiple sheets you ' . + 'must implement close_sheet() and close_output() and remove write_footer() in your dataformat.', DEBUG_DEVELOPER); $format->write_footer($columns); } else { $format->close_sheet($columns); @@ -89,3 +94,70 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca } } +/** + * Writes a formatted data file to specified path + * + * @package core + * @subpackage dataformat + * + * @param string $filename The base filename without an extension + * @param string $dataformat A dataformat name + * @param array $columns An ordered map of column keys and labels + * @param Iterator $iterator An iterator over the records, usually a RecordSet + * @param callable $callback An option function applied to each record before writing + * @return string Complete path to written file + * @throws coding_exception + */ +function write_file_as_dataformat(string $filename, string $dataformat, array $columns, $iterator, + callable $callback = null): string { + + $classname = 'dataformat_' . $dataformat . '\writer'; + if (!class_exists($classname)) { + throw new coding_exception("Unable to locate dataformat/$dataformat/classes/writer.php"); + } + + // The data format export could take a while to generate. + core_php_time_limit::raise(); + + // Close the session so that the users other tabs in the same session are not blocked. + \core\session\manager::write_close(); + + /** @var \core\dataformat\base $format */ + $format = new $classname; + + $filepath = make_request_directory() . '/' . $filename . $format->get_extension(); + $format->set_filepath($filepath); + + // This exists to support all dataformats - see MDL-56046. + if (!method_exists($format, 'start_output_to_file') && method_exists($format, 'write_header')) { + debugging('The function write_header() does not support multiple sheets. In order to support multiple sheets you must ' . + 'implement start_output_to_file() and start_sheet() and remove write_header() in your dataformat.', DEBUG_DEVELOPER); + $format->write_header($columns); + } else { + $format->start_output_to_file(); + $format->start_sheet($columns); + } + + $c = 0; + foreach ($iterator as $row) { + if ($callback) { + $row = $callback($row); + } + if ($row === null) { + continue; + } + $format->write_record($row, $c++); + } + + // This exists to support all dataformats - see MDL-56046. + if (!method_exists($format, 'close_output_to_file') && method_exists($format, 'write_footer')) { + debugging('The function write_footer() does not support multiple sheets. In order to support multiple sheets you must ' . + 'implement close_sheet() and close_output_to_file() and remove write_footer() in your dataformat.', DEBUG_DEVELOPER); + $format->write_footer($columns); + } else { + $format->close_sheet($columns); + $format->close_output_to_file(); + } + + return $filepath; +} diff --git a/lib/tests/dataformat_test.php b/lib/tests/dataformat_test.php new file mode 100644 index 00000000000..3e274bda5a6 --- /dev/null +++ b/lib/tests/dataformat_test.php @@ -0,0 +1,80 @@ +. + +/** + * Tests for the dataformat plugins + * + * @package core + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core; + +use core_component; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->libdir}/dataformatlib.php"); + +/** + * Dataformat tests + * + * @package core + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dataformat_testcase extends \advanced_testcase { + + /** + * Data provider for {@see test_write_file_as_dataformat} + * + * @return array + */ + public function write_file_as_dataformat_provider(): array { + $data = []; + + $dataformats = core_component::get_plugin_list('dataformat'); + foreach ($dataformats as $dataformat => $unused) { + $data[] = [$dataformat]; + } + + return $data; + } + + /** + * Test writing dataformat export to local file + * + * @param string $dataformat + * @return void + * + * @dataProvider write_file_as_dataformat_provider + */ + public function test_write_file_as_dataformat(string $dataformat): void { + $columns = ['fruit', 'colour', 'animal']; + $rows = [ + ['banana', 'yellow', 'monkey'], + ['apple', 'red', 'wolf'], + ['melon', 'green', 'aardvark'], + ]; + + // Export to file. Assert that the exported file exists and is non-zero in size. + $exportfile = write_file_as_dataformat('My export', $dataformat, $columns, $rows); + $this->assertFileExists($exportfile); + $this->assertGreaterThan(0, filesize($exportfile)); + } +}