1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5use dokuwiki\plugin\struct\types\Page; 6 7/** 8 * Class CSVImporter 9 * 10 * Imports CSV data 11 * 12 * @package dokuwiki\plugin\struct\meta 13 */ 14class CSVImporter 15{ 16 /** @var Schema */ 17 protected $schema; 18 19 /** @var resource */ 20 protected $fh; 21 22 /** @var \helper_plugin_sqlite */ 23 protected $sqlite; 24 25 /** @var Column[] The single values to store index => col */ 26 protected $columns = []; 27 28 /** @var int current line number */ 29 protected $line = 0; 30 31 /** @var array list of headers */ 32 protected $header; 33 34 /** @var array list of validation errors */ 35 protected $errors; 36 37 /** 38 * @var string data type, must be one of page, global, serial 39 */ 40 protected $type; 41 42 /** 43 * CSVImporter constructor. 44 * 45 * @param string $table 46 * @param string $file 47 * @param string $type 48 */ 49 public function __construct($table, $file, $type) 50 { 51 $this->type = $type; 52 $this->openFile($file); 53 54 $this->schema = new Schema($table); 55 if (!$this->schema->getId()) throw new StructException('Schema does not exist'); 56 57 /** @var \helper_plugin_struct_db $db */ 58 $db = plugin_load('helper', 'struct_db'); 59 $this->sqlite = $db->getDB(true); 60 } 61 62 /** 63 * Import the data from file. 64 * 65 * @throws StructException 66 */ 67 public function import() 68 { 69 // Do the import 70 $this->readHeaders(); 71 $this->importCSV(); 72 } 73 74 /** 75 * Open a given file path 76 * 77 * The main purpose of this method is to be overridden in a mock for testing 78 * 79 * @param string $file the file path 80 * 81 * @return void 82 */ 83 protected function openFile($file) 84 { 85 $this->fh = fopen($file, 'rb'); 86 if (!$this->fh) { 87 throw new StructException('Failed to open CSV file for reading'); 88 } 89 } 90 91 /** 92 * Get a parsed line from the opened CSV file 93 * 94 * The main purpose of this method is to be overridden in a mock for testing 95 * 96 * @return array|false|null 97 */ 98 protected function getLine() 99 { 100 return fgetcsv($this->fh); 101 } 102 103 /** 104 * Read the CSV headers and match it with the Schema columns 105 */ 106 protected function readHeaders() 107 { 108 $header = $this->getLine(); 109 if (!$header) throw new StructException('Failed to read CSV'); 110 $this->line++; 111 112 // we might have to create a page column first 113 if ($this->type !== CSVExporter::DATATYPE_GLOBAL) { 114 $pageType = new Page(null, 'pid'); 115 $pidCol = new Column(0, $pageType, 0, true, $this->schema->getTable()); 116 $this->columns[] = $pidCol; 117 } 118 119 foreach ($header as $i => $head) { 120 $col = $this->schema->findColumn($head); 121 // just skip the checks for 'pid' but discard other columns not present in the schema 122 if (!$col) { 123 if ($head !== 'pid') { 124 unset($header[$i]); 125 } 126 continue; 127 } 128 if (!$col->isEnabled()) continue; 129 $this->columns[$i] = $col; 130 } 131 132 if (!$this->columns) { 133 throw new StructException('None of the CSV headers matched any of the schema\'s fields'); 134 } 135 136 $this->header = $header; 137 } 138 139 /** 140 * Walks through the CSV and imports 141 */ 142 protected function importCSV() 143 { 144 while (($data = $this->getLine()) !== false) { 145 $this->line++; 146 $this->importLine($data); 147 } 148 } 149 150 /** 151 * The errors that occured during validation 152 * 153 * @return string[] already translated error messages 154 */ 155 public function getErrors() 156 { 157 return $this->errors; 158 } 159 160 /** 161 * Validate a single value 162 * 163 * @param Column $col the column of that value 164 * @param mixed &$rawvalue the value, will be fixed according to the type 165 * @return bool true if the data validates, otherwise false 166 */ 167 protected function validateValue(Column $col, &$rawvalue) 168 { 169 //by default no validation 170 return true; 171 } 172 173 /** 174 * Read and validate CSV parsed line 175 * 176 * @param $line 177 * @return array|bool 178 */ 179 protected function readLine($line) 180 { 181 // prepare values for single value table 182 $values = []; 183 foreach ($this->columns as $i => $column) { 184 if (!isset($line[$i])) throw new StructException('Missing field at CSV line %d', $this->line); 185 186 if (!$this->validateValue($column, $line[$i])) return false; 187 188 if ($column->isMulti()) { 189 // multi values get split on comma, but JSON values contain commas too, hence preg_split 190 if ($line[$i][0] === '[') { 191 $line[$i] = preg_split('/,(?=\[)/', $line[$i]); 192 } else { 193 $line[$i] = array_map('trim', explode(',', $line[$i])); 194 } 195 } 196 // data access will handle multivalues, no need to manipulate them here 197 $values[] = $line[$i]; 198 } 199 //if no ok don't import 200 return $values; 201 } 202 203 /** 204 * Save one CSV line into database 205 * 206 * @param string[] $values parsed line values 207 */ 208 protected function saveLine($values) 209 { 210 $data = array_combine($this->header, $values); 211 // pid is a non-data column and must be supplied to the AccessTable separately 212 $pid = $data['pid'] ?? ''; 213 unset($data['pid']); 214 $table = $this->schema->getTable(); 215 216 /** @var 'helper_plugin_struct $helper */ 217 $helper = plugin_load('helper', 'struct'); 218 if ($this->type === CSVExporter::DATATYPE_PAGE) { 219 $helper->saveData($pid, [$table => $data], 'CSV data imported'); 220 return; 221 } 222 if ($this->type === CSVExporter::DATATYPE_SERIAL) { 223 $access = AccessTable::getSerialAccess($table, $pid); 224 } else { 225 $access = AccessTable::getGlobalAccess($table); 226 } 227 $helper->saveLookupData($access, $data); 228 } 229 230 /** 231 * Imports one line into the schema 232 * 233 * @param string[] $line the parsed CSV line 234 */ 235 protected function importLine($line) 236 { 237 //read values, false if invalid, empty array if the same as current data 238 $values = $this->readLine($line); 239 240 if ($values) { 241 $this->saveLine($values); 242 } else foreach ($this->errors as $error) { 243 msg($error, -1); 244 } 245 } 246} 247