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