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