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