Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace mod_data\local\exporter;
  18  
  19  use file_serving_exception;
  20  use moodle_exception;
  21  use zip_archive;
  22  
  23  /**
  24   * Exporter class for exporting data and - if needed - files as well in a zip archive.
  25   *
  26   * @package    mod_data
  27   * @copyright  2023 ISB Bayern
  28   * @author     Philipp Memmel
  29   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  30   */
  31  abstract class entries_exporter {
  32  
  33      /** @var int Tracks the currently edited row of the export data file. */
  34      private int $currentrow;
  35  
  36      /**
  37       * @var array The data structure containing the data for exporting. It's a 2-dimensional array of
  38       *  rows and columns.
  39       */
  40      protected array $exportdata;
  41  
  42      /** @var string Name of the export file name without extension. */
  43      protected string $exportfilename;
  44  
  45      /** @var zip_archive The zip archive object we store all the files in, if we need to export files as well. */
  46      private zip_archive $ziparchive;
  47  
  48      /** @var bool Tracks the state if the zip archive already has been closed. */
  49      private bool $isziparchiveclosed;
  50  
  51      /** @var string full path of the zip archive. */
  52      private string $zipfilepath;
  53  
  54      /** @var array Array to store all filenames in the zip archive for export. */
  55      private array $filenamesinzip;
  56  
  57      /**
  58       * Creates an entries_exporter object.
  59       *
  60       * This object can be used to export data to different formats including files. If files are added,
  61       * everything will be bundled up in a zip archive.
  62       */
  63      public function __construct() {
  64          $this->currentrow = 0;
  65          $this->exportdata = [];
  66          $this->exportfilename = 'Exportfile';
  67          $this->filenamesinzip = [];
  68          $this->isziparchiveclosed = true;
  69      }
  70  
  71      /**
  72       * Adds a row (array of strings) to the export data.
  73       *
  74       * @param array $row the row to add, $row has to be a plain array of strings
  75       * @return void
  76       */
  77      public function add_row(array $row): void {
  78          $this->exportdata[] = $row;
  79          $this->currentrow++;
  80      }
  81  
  82      /**
  83       * Adds a data string (so the content for a "cell") to the current row.
  84       *
  85       * @param string $cellcontent the content to add to the current row
  86       * @return void
  87       */
  88      public function add_to_current_row(string $cellcontent): void {
  89          $this->exportdata[$this->currentrow][] = $cellcontent;
  90      }
  91  
  92      /**
  93       * Signal the entries_exporter to finish the current row and jump to the next row.
  94       *
  95       * @return void
  96       */
  97      public function next_row(): void {
  98          $this->currentrow++;
  99      }
 100  
 101      /**
 102       * Sets the name of the export file.
 103       *
 104       * Only use the basename without path and without extension here.
 105       *
 106       * @param string $exportfilename name of the file without path and extension
 107       * @return void
 108       */
 109      public function set_export_file_name(string $exportfilename): void {
 110          $this->exportfilename = $exportfilename;
 111      }
 112  
 113      /**
 114       * The entries_exporter will prepare a data file from the rows and columns being added.
 115       * Overwrite this method to generate the data file as string.
 116       *
 117       * @return string the data file as a string
 118       */
 119      abstract protected function get_data_file_content(): string;
 120  
 121      /**
 122       * Overwrite the method to return the file extension your data file will have, for example
 123       * <code>return 'csv';</code> for a csv file entries_exporter.
 124       *
 125       * @return string the file extension of the data file your entries_exporter is using
 126       */
 127      abstract protected function get_export_data_file_extension(): string;
 128  
 129      /**
 130       * Returns the count of currently stored records (rows excluding header row).
 131       *
 132       * @return int the count of records/rows
 133       */
 134      public function get_records_count(): int {
 135          // The attribute $this->exportdata also contains a header. If only one row is present, this
 136          // usually is the header, so record count should be 0.
 137          if (count($this->exportdata) <= 1) {
 138              return 0;
 139          }
 140          return count($this->exportdata) - 1;
 141      }
 142  
 143      /**
 144       * Use this method to add a file which should be exported to the entries_exporter.
 145       *
 146       * @param string $filename the name of the file which should be added
 147       * @param string $filecontent the content of the file as a string
 148       * @param string $zipsubdir the subdirectory in the zip archive. Defaults to 'files/'.
 149       * @return void
 150       * @throws moodle_exception if there is an error adding the file to the zip archive
 151       */
 152      public function add_file_from_string(string $filename, string $filecontent, string $zipsubdir = 'files/'): void {
 153          if (empty($this->filenamesinzip)) {
 154              // No files added yet, so we need to create a zip archive.
 155              $this->create_zip_archive();
 156          }
 157          if (!str_ends_with($zipsubdir, '/')) {
 158              $zipsubdir .= '/';
 159          }
 160          $zipfilename = $zipsubdir . $filename;
 161          $this->filenamesinzip[] = $zipfilename;
 162          $this->ziparchive->add_file_from_string($zipfilename, $filecontent);
 163      }
 164  
 165      /**
 166       * Sends the generated export file.
 167       *
 168       * Care: By default this function finishes the current PHP request and directly serves the file to the user as download.
 169       *
 170       * @param bool $sendtouser true if the file should be sent directly to the user, if false the file content will be returned
 171       *  as string
 172       * @return string|null file content as string if $sendtouser is true
 173       * @throws moodle_exception if there is an issue adding the data file
 174       * @throws file_serving_exception if the file could not be served properly
 175       */
 176      public function send_file(bool $sendtouser = true): null|string {
 177          if (empty($this->filenamesinzip)) {
 178              if ($sendtouser) {
 179                  send_file($this->get_data_file_content(),
 180                      $this->exportfilename . '.' . $this->get_export_data_file_extension(),
 181                      null, 0, true, true);
 182                  return null;
 183              } else {
 184                  return $this->get_data_file_content();
 185              }
 186          }
 187          $this->add_file_from_string($this->exportfilename . '.' . $this->get_export_data_file_extension(),
 188              $this->get_data_file_content(), '/');
 189          $this->finish_zip_archive();
 190  
 191          if ($this->isziparchiveclosed) {
 192              if ($sendtouser) {
 193                  send_file($this->zipfilepath, $this->exportfilename . '.zip', null, 0, false, true);
 194                  return null;
 195              } else {
 196                  return file_get_contents($this->zipfilepath);
 197              }
 198          } else {
 199              throw new file_serving_exception('Could not serve zip file, it could not be closed properly.');
 200          }
 201      }
 202  
 203      /**
 204       * Checks if a file with the given name has already been added to the file export bundle.
 205       *
 206       * Care: Filenames are compared to all files in the specified zip subdirectory which
 207       *  defaults to 'files/'.
 208       *
 209       * @param string $filename the filename containing the zip path of the file to check
 210       * @param string $zipsubdir The subdirectory in which the filename should be looked for,
 211       *  defaults to 'files/'
 212       * @return bool true if file with the given name already exists, false otherwise
 213       */
 214      public function file_exists(string $filename, string $zipsubdir = 'files/'): bool {
 215          if (!str_ends_with($zipsubdir, '/')) {
 216              $zipsubdir .= '/';
 217          }
 218          if (empty($filename)) {
 219              return false;
 220          }
 221          return in_array($zipsubdir . $filename, $this->filenamesinzip, true);
 222      }
 223  
 224      /**
 225       * Creates a unique filename based on the given filename.
 226       *
 227       * This method adds "_1", "_2", ... to the given file name until the newly generated filename
 228       * is not equal to any of the already saved ones in the export file bundle.
 229       *
 230       * @param string $filename the filename based on which a unique filename should be generated
 231       * @return string the unique filename
 232       */
 233      public function create_unique_filename(string $filename): string {
 234          if (!$this->file_exists($filename)) {
 235              return $filename;
 236          }
 237  
 238          $extension = pathinfo($filename, PATHINFO_EXTENSION);
 239          $filenamewithoutextension = empty($extension)
 240              ? $filename
 241              : substr($filename, 0,strlen($filename) - strlen($extension) - 1);
 242          $filenamewithoutextension = $filenamewithoutextension . '_1';
 243          $i = 1;
 244          $filename = empty($extension) ? $filenamewithoutextension : $filenamewithoutextension . '.' . $extension;
 245          while ($this->file_exists($filename)) {
 246              // In case we have already a file ending with '_XX' where XX is an ascending number, we have to
 247              // remove '_XX' first before adding '_YY' again where YY is the successor of XX.
 248              $filenamewithoutextension = preg_replace('/_' . $i . '$/', '_' . ($i + 1), $filenamewithoutextension);
 249              $filename = empty($extension) ? $filenamewithoutextension : $filenamewithoutextension . '.' . $extension;
 250              $i++;
 251          }
 252          return $filename;
 253      }
 254  
 255      /**
 256       * Prepares the zip archive.
 257       *
 258       * @return void
 259       */
 260      private function create_zip_archive(): void {
 261          $tmpdir = make_request_directory();
 262          $this->zipfilepath = $tmpdir . '/' . $this->exportfilename . '.zip';
 263          $this->ziparchive = new zip_archive();
 264          $this->isziparchiveclosed = !$this->ziparchive->open($this->zipfilepath);
 265      }
 266  
 267      /**
 268       * Closes the zip archive.
 269       *
 270       * @return void
 271       */
 272      private function finish_zip_archive(): void {
 273          if (!$this->isziparchiveclosed) {
 274              $this->isziparchiveclosed = $this->ziparchive->close();
 275          }
 276      }
 277  }