PluginProbe ʕ •ᴥ•ʔ
VikAppointments Services Booking Calendar / 1.2.21
VikAppointments Services Booking Calendar v1.2.21
1.2.21 1.2.20 trunk 1.2.17 1.2.18 1.2.19
vikappointments / site / helpers / libraries / import / object.php
vikappointments / site / helpers / libraries / import Last commit date
classes 3 days ago export 3 days ago forms 3 days ago samples 3 days ago column.php 3 days ago exportable.php 3 days ago factory.php 3 days ago index.html 3 days ago object.php 3 days ago
object.php
884 lines
1 <?php
2 /**
3 * @package VikAppointments
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2021 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 VAPLoader::import('libraries.import.column');
15
16 /**
17 * Class used to handle a generic import event.
18 * This class is able to import a list of records starting
19 * from a CSV file.
20 *
21 * The CSV must start with a valid heading, otherwise the first row
22 * will be skipped.
23 *
24 * @since 1.6
25 */
26 class ImportObject
27 {
28 /**
29 * The XML instructions object.
30 *
31 * @var SimpleXMLElement
32 */
33 protected $xml;
34
35 /**
36 * The import entity type.
37 *
38 * @var string
39 */
40 protected $type;
41
42 /**
43 * Property used to load the table columns only once.
44 *
45 * @var array
46 * @since 1.7
47 */
48 protected $columns = null;
49
50 /**
51 * The filter input handler.
52 *
53 * @var mixed
54 */
55 protected $filter;
56
57 /**
58 * The path of the file to import.
59 *
60 * @var string
61 */
62 protected $file = null;
63
64 /**
65 * The total number of records fetched.
66 * Used by both the import and export methods.
67 *
68 * @var integer
69 */
70 protected $total = 0;
71
72 /**
73 * A list of errors.
74 *
75 * @var array
76 */
77 protected $errors = array();
78
79 /**
80 * Class constructor.
81 *
82 * @param object $xml The XML object.
83 * @param string $type The entity type to import.
84 *
85 */
86 public function __construct($xml, $type)
87 {
88 $this->xml = $xml;
89 $this->type = $type;
90 $this->filter = JFilterInput::getInstance();
91 }
92
93 /**
94 * Returns the path of the file to import.
95 * The path found is always cached to avoid retrieving
96 * it during the next accesses.
97 *
98 * @return mixed The file path if exists, otherwise false.
99 */
100 public function getFile()
101 {
102 if ($this->file === null)
103 {
104 $folder = VAPADMIN . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR;
105
106 $file = glob($folder . $this->type . '_*.csv');
107
108 if (count($file))
109 {
110 $this->file = array_pop($file);
111 }
112 else
113 {
114 $this->file = false;
115 }
116 }
117
118 return $this->file;
119 }
120
121 /**
122 * Checks if the file is ready to be imported.
123 *
124 * @return boolean True if the file exists, otherwise false.
125 *
126 * @uses getFile()
127 */
128 public function hasFile()
129 {
130 return (bool) $this->getFile();
131 }
132
133 /**
134 * Returns the database table to use while importing the records.
135 *
136 * @param boolean True to return a database table instance.
137 * Otherwise the database table name will be returned.
138 *
139 * @return mixed The DB table name or a JTable instance.
140 */
141 public function getTable($object = false)
142 {
143 /**
144 * Check whether we should return a JTable instance.
145 *
146 * @since 1.7
147 */
148 if ($object)
149 {
150 // create table instance
151 return JTableVAP::getInstance($this->xml->table->attributes()->id, 'VAPTable');
152 }
153
154 // return table name
155 return (string) $this->xml->table->attributes()->name;
156 }
157
158 /**
159 * Returns the primary key of the database table.
160 *
161 * @param string $def The default primary key to use
162 * if not specified.
163 *
164 * @return string The primary key.
165 */
166 public function getPrimaryKey($def = 'id')
167 {
168 $pk = (string) $this->xml->table->attributes()->pk;
169
170 if (empty($pk))
171 {
172 $pk = $def;
173 }
174
175 return $pk;
176 }
177
178 /**
179 * Returns all the available columns that can be assigned
180 * to the values listed in the CSV file.
181 * @since 1.7 columns are always translated and loaded once.
182 *
183 * @return array The list of available columns.
184 */
185 public function getColumns()
186 {
187 if (is_null($this->columns))
188 {
189 $this->columns = array();
190
191 foreach ($this->xml->table->column as $column)
192 {
193 /**
194 * Use an apposite instance to hold the column attributes.
195 * For backward compatibility, the instance will act as
196 * a plain object.
197 *
198 * @since 1.7
199 */
200 $obj = ImportColumn::getInstance($column);
201
202 // skip in case the column didn't specify a name
203 if ($obj->name)
204 {
205 $this->columns[$obj->name] = $obj;
206 }
207 }
208
209 $dispatcher = VAPFactory::getEventDispatcher();
210
211 /**
212 * Trigger event to let external plugins be able to include
213 * additional columns that are not mentioned within the default
214 * XML file of this import/export type.
215 *
216 * Any attached element should support the following parameters:
217 * - name string the column name equals to array key (mandatory);
218 * - label string the column readable label (optional);
219 * - required bool true if required (optional);
220 * - default mixed the default value if missing (optional);
221 * - filter string the filter type (optional);
222 * - type string the type of column (optional);
223 * - options array an array of placeholders (optional);
224 *
225 * @param string $type The current import/export type.
226 *
227 * @return array A list of columns to append.
228 *
229 * @since 1.7
230 */
231 $results = $dispatcher->trigger('onLoadImportExportColumns', array($this->type));
232
233 // iterate all returned results
234 foreach ($results as $arr)
235 {
236 foreach ($arr as $column)
237 {
238 // instantiate column with returned properties
239 $obj = ImportColumn::getInstance($column);
240
241 // skip in case the column didn't specify a name
242 if ($obj->name)
243 {
244 $this->columns[$obj->name] = $obj;
245 }
246 }
247 }
248 }
249
250 return $this->columns;
251 }
252
253 /**
254 * Returns the cancellation task, if any.
255 *
256 * @return mixed The cancel task if specified, otherwise false.
257 */
258 public function getCancelTask()
259 {
260 $task = (string) $this->xml->cancel->attributes()->task;
261
262 if (empty($task))
263 {
264 return false;
265 }
266
267 return $task;
268 }
269
270 /**
271 * Returns the file containing the sample data to import this type of object.
272 *
273 * @return mixed The file path if exists, otherwise false.
274 */
275 public function getSampleFile()
276 {
277 $sample = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'samples' . DIRECTORY_SEPARATOR . $this->type . '.csv';
278
279 if (file_exists($sample))
280 {
281 return $sample;
282 }
283
284 return false;
285 }
286
287 /**
288 * Checks if this object owns any sample data file.
289 *
290 * @return boolean True if exists, otherwise false.
291 *
292 * @uses getSampleFile()
293 */
294 public function hasSampleFile()
295 {
296 return $this->getSampleFile() !== false;
297 }
298
299 /**
300 * Returns a preview of the records contained in the file.
301 *
302 * @param integer $lim The maximum number of records to obtain.
303 *
304 * @return array The records list.
305 *
306 * @uses getFile()
307 */
308 public function getRecords($lim = 10)
309 {
310 $rows = array();
311
312 $file = $this->getFile();
313
314 $handle = fopen($file, 'r');
315
316 /**
317 * Auto-detect the best separator and enclosure to use.
318 *
319 * @since 1.7.8
320 */
321 list($separator, $enclosure) = $this->detectCsvFormat($handle);
322
323 $count = 0;
324
325 while (($buffer = fgetcsv($handle, $length = 0, $separator, $enclosure, $escape = '')) && $count <= $lim)
326 {
327 $rows[] = $buffer;
328 $count++;
329 }
330
331 fclose($handle);
332
333 return $rows;
334 }
335
336 /**
337 * Returns the total number of records fetched.
338 *
339 * @return integer The total count.
340 */
341 public function getTotalCount()
342 {
343 return $this->total;
344 }
345
346 /**
347 * Pushes a new error in the list.
348 *
349 * @param object $data The record failed.
350 * @param string $err The error message.
351 *
352 * @return self This object to support chaining.
353 */
354 protected function setError($data, $err = '')
355 {
356 if (empty($err))
357 {
358 $err = JText::translate('VAPIMPORTINSERTERR');
359 }
360
361 $str = '<b>' . $err . '</b><br />';
362
363 $data = (array) $data;
364
365 if ($data)
366 {
367 $str .= '<pre>' . implode(', ', $data) . '</pre>';
368 }
369
370 $this->errors[] = $str;
371
372 return $this;
373 }
374
375 /**
376 * Returns a list of errors raised.
377 *
378 * @return array An errors list.
379 */
380 public function getErrors()
381 {
382 return $this->errors;
383 }
384
385 /**
386 * Processes the event to import all the records
387 * contained in the CSV file.
388 *
389 * @param array $assoc Associative array used to match
390 * the columns of the table with the columns
391 * of the CSV records.
392 * @param array $args Associative list of additional parameters.
393 *
394 * @return integer The number of imported records.
395 *
396 * @uses getColumns()
397 * @uses getFile()
398 * @uses getTable()
399 * @uses getPrimaryKey()
400 * @uses bind()
401 */
402 public function save(array $assoc, array $args = array())
403 {
404 $dbo = JFactory::getDbo();
405
406 $cols = $this->getColumns();
407 $file = $this->getFile();
408 $table = $this->getTable($object = true);
409 $pk = $this->getPrimaryKey();
410
411 // make sure this object supports a database table instance
412 if (!$table)
413 {
414 // nope, lets load the database table name
415 $table = $this->getTable();
416 }
417
418 $handle = fopen($file, 'r');
419
420 $count = $this->total = 0;
421
422 // reset errors list
423 $this->errors = array();
424
425 $head = null;
426
427 /**
428 * Auto-detect the best separator and enclosure to use.
429 *
430 * @since 1.7.8
431 */
432 list($separator, $enclosure) = $this->detectCsvFormat($handle);
433
434 while (($buffer = fgetcsv($handle, $length = 0, $separator, $enclosure, $escape = '')))
435 {
436 if ($head === null)
437 {
438 $head = $buffer;
439 }
440 else
441 {
442 $record = new stdClass;
443 $valid = true;
444
445 foreach ($buffer as $k => $v)
446 {
447 if (!empty($assoc[$head[$k]]))
448 {
449 // get the column related to the specified CSV head
450 $column = $assoc[$head[$k]];
451
452 // if the value is empty, try to use the default column value
453 if (strlen($v) == 0 && !empty($cols[$column]->default))
454 {
455 $v = $cols[$column]->default;
456 }
457 else
458 {
459 /**
460 * Check whether this column requires some adjustments.
461 *
462 * @since 1.7
463 */
464 $v = $cols[$column]->onImport($v);
465 }
466
467 // try to filter the specified value
468 if (!empty($cols[$column]->filter))
469 {
470 $v = $this->filter->clean($v, $cols[$column]->filter);
471 }
472
473 // check if the value MUST NOT be empty
474 if (empty($v) && $cols[$column]->required)
475 {
476 // empty required value, the object
477 // should not be imported
478 $valid = false;
479 }
480
481 $record->{$assoc[$head[$k]]} = $v;
482 }
483 }
484
485 if ($valid && $this->bind($record, $args))
486 {
487 $msg = null;
488
489 if ($table instanceof JTable)
490 {
491 // always reset table before creating a new record
492 $table->reset();
493 // unset primary key
494 $record->{$table->getKeyName()} = 0;
495
496 // attempt to save by using the table instance
497 $res = $table->save($record);
498
499 if (!$res)
500 {
501 // get registered error message
502 $msg = $table->getError();
503 }
504 }
505 else
506 {
507 try
508 {
509 // table not supported, use direct DB insert
510 $res = $dbo->insertObject($table, $record, $pk) && $record->{$pk};
511 }
512 catch (Exception $e)
513 {
514 $res = false;
515 $msg = $e->getMessage();
516 }
517 }
518
519 if ($res)
520 {
521 // imported
522 $count++;
523 }
524 else
525 {
526 // an error occurred
527 $this->setError($record, $msg);
528 }
529 }
530
531 $this->total++;
532 }
533 }
534
535 fclose($handle);
536
537 return $count;
538 }
539
540 /**
541 * Method used to bind the provided object. By returning
542 * false the system won't proceed importing the current record.
543 *
544 * A record won't be imported if it doesn't own any property.
545 *
546 * @param object &$data The object of the record to import.
547 * @param array $args Associative list of additional parameters.
548 *
549 * @return boolean True if the record should be imported, otherwise false.
550 */
551 protected function bind(&$data, array $args = array())
552 {
553 $vars = get_object_vars($data);
554
555 return !is_null($vars) && count(array_keys($vars));
556 }
557
558 /**
559 * Returns a list of the records to export.
560 * @since 1.7 $full argument has been replaced by an array of options,
561 * which now includes it as attribute.
562 *
563 * @param array $options An array of export options.
564 * - full bool true to use a query limit;
565 * - raw bool true to format the records values;
566 * - columns array a list of columns to export.
567 *
568 * @return array The records to export.
569 *
570 * @uses buildExportQuery()
571 * @uses formatRecords()
572 */
573 public function getExportableRows($options = array())
574 {
575 $app = JFactory::getApplication();
576 $dbo = JFactory::getDbo();
577
578 // check whether the options is a boolean for BC
579 if (is_bool($options))
580 {
581 // define options with given full value
582 $options = array('full' => $options);
583 }
584
585 // include application and database within options array
586 $options['app'] = $app;
587 $options['dbo'] = $dbo;
588
589 // Create a registry for ease of use.
590 // Use JObject in place of JRegistry because this
591 // one accepts only standard elements.
592 $options = new JObject($options);
593
594 $q = $this->buildExportQuery($options);
595
596 if ($options->get('full', false))
597 {
598 // unset limit
599 $lim0 = $lim = null;
600 }
601 else
602 {
603 $lim0 = 0;
604 $lim = 10;
605 }
606
607 $dbo->setQuery($q, $lim0, $lim);
608 $rows = $dbo->loadAssocList();
609
610 if ($rows)
611 {
612 /**
613 * Check whether we should format the record
614 * while exporting them.
615 *
616 * @since 1.7
617 */
618 if (!$options->get('raw'))
619 {
620 $this->formatRecords($rows);
621 }
622
623 // get the total number of rows
624 $dbo->setQuery('SELECT FOUND_ROWS();');
625 $this->total = $dbo->loadResult();
626
627 return $rows;
628 }
629
630 return array();
631 }
632
633 /**
634 * Builds the base query to export all the records.
635 * @since 1.7 $app and $dbo are now included within the $options argument.
636 *
637 * @param JObject $options A registry of export options.
638 * @param string $alias The table alias.
639 *
640 * @return mixed The query builder object.
641 *
642 * @uses getColumns()
643 * @uses getTable()
644 * @uses getPrimaryKey()
645 */
646 protected function buildExportQuery($options, $alias = '')
647 {
648 $app = $options->get('app');
649 $dbo = $options->get('dbo');
650
651 $columns = $this->getColumns();
652 $table = $this->getTable();
653 $pk = $this->getPrimaryKey();
654
655 $q = $dbo->getQuery(true);
656
657 /**
658 * Implemented table alias to support joins with
659 * external tables in children classes.
660 *
661 * @since 1.7
662 */
663 $alias = $alias ? $alias : 't';
664
665 // get list of columns to introduce within the query
666 $queryColumns = array_keys($columns);
667
668 /**
669 * Check whether we need to exclude some columns from the query.
670 *
671 * @since 1.7
672 */
673 if ($selectedColumns = $options->get('columns', array()))
674 {
675 // take only the columns included within the list
676 $queryColumns = array_filter($queryColumns, function($col) use ($selectedColumns)
677 {
678 return in_array($col, $selectedColumns);
679 });
680 }
681
682 // Calculate the total number of records fetched.
683 // Pop the first column to concat SQL_CALC_FOUND_ROWS.
684 $q->select('SQL_CALC_FOUND_ROWS ' . $dbo->qn($alias . '.' . array_shift($queryColumns)));
685
686 // map the columns to select
687 foreach ($queryColumns as $col)
688 {
689 $q->select($dbo->qn($alias . '.' . $col));
690 }
691
692 // define the table to access
693 $q->from($dbo->qn($table, $alias));
694
695 $ids = $app->input->get('cid', array(), 'string');
696
697 if (count($ids))
698 {
699 // map the array to quote each element
700 $ids = array_map(array($dbo, 'q'), $ids);
701 // build IN statement
702 $q->where($dbo->qn($alias . '.' . $pk) . ' IN (' . implode(', ', $ids) . ')');
703 }
704
705 // create hook for query manipulation
706 $event = 'onBeforeListQuery' . ucfirst($this->type);
707
708 // Create a dummy object to replicate the behavior of a view.
709 // Not the best solution but does its job, at least until
710 // List models will be implemented...
711 $view = new stdClass;
712 $view->filters = array();
713 $view->ordering = 'id';
714 $view->orderingDir = 'asc';
715
716 /**
717 * Replicate same hook used by the view in order to keep the
718 * custom filters also while exporting the records.
719 *
720 * @since 1.7
721 */
722 VAPFactory::getEventDispatcher()->trigger($event, array(&$q, $view));
723
724 return $q;
725 }
726
727 /**
728 * Formats the records according to the type of the columns
729 * specified with the XML manifest.
730 *
731 * @param array &$rows The rows to export.
732 *
733 * @return void
734 *
735 * @since 1.7
736 *
737 * @uses getColumns()
738 */
739 protected function formatRecords(&$rows)
740 {
741 // get support columns
742 $columns = $this->getColumns();
743
744 // iterate exported records
745 foreach ($rows as $i => $row)
746 {
747 // iterate columns of current record
748 foreach ($row as $k => $v)
749 {
750 // make sure the column exists because the query of the import handler
751 // might have manually selected the columns of another table
752 if (isset($columns[$k]))
753 {
754 // attempt to format the value
755 $rows[$i][$k] = $columns[$k]->format($v);
756 }
757 }
758 }
759 }
760
761 /**
762 * Exports the records using the given handler.
763 *
764 * @param Exportable $handler The export handler.
765 * @param string $name The file name.
766 * @param array $options An array of export options.
767 *
768 * @return void
769 *
770 * @uses getExportableRows()
771 */
772 public function export($handler, $name)
773 {
774 /**
775 * Extract export options from handler.
776 *
777 * @since 1.7
778 */
779 $options = $handler->getOptions();
780
781 // ignore query limits
782 $options['full'] = true;
783
784 // get rows to export
785 $rows = $this->getExportableRows($options);
786
787 if (!$name)
788 {
789 $name = strtolower($this->type);
790 }
791
792 // export the records
793 $handler->download($name, $rows, $this);
794 }
795
796 /**
797 * Detects the separator and enclosure used by the specified CSV file.
798 *
799 * @param object $file The file pointer of the CSV to scan.
800 * @param int $checkLines The number of lines to read and test.
801 *
802 * @return string[] A 2-elements array holding the separator and the enclosure.
803 *
804 * @since 1.7.8
805 */
806 protected function detectCsvFormat($file, int $checkLines = 5) {
807 // define all the supported separators and enclosures
808 $separators = [",", ";", "\t", "|"];
809 $enclosures = ['"', "'"];
810
811 $lines = [];
812
813 // read the first N lines to detect the separator and the enclosure
814 while (($line = fgets($file)) !== false && count($lines) < $checkLines)
815 {
816 $lines[] = trim($line);
817 }
818
819 // move the cursor at the beginning of the file
820 rewind($file);
821
822 // detect the separator used in the CSV
823 $bestSeparator = ',';
824 $maxFields = 0;
825
826 // test all the supported separators
827 foreach ($separators as $sep)
828 {
829 $fieldCounts = [];
830
831 // for each line, count the number of columns extracted by using the current separator
832 foreach ($lines as $line)
833 {
834 // save columns count line per line
835 $fieldCounts[] = count(str_getcsv($line, $sep));
836 }
837
838 // make sure all the lines share the same number of columns and make sure the new count
839 // is higher than the one registered for the previous separator
840 if (count(array_unique($fieldCounts)) === 1 && $fieldCounts[0] > $maxFields)
841 {
842 // a new optimal separator has been found
843 $bestSeparator = $sep;
844 $maxFields = $fieldCounts[0];
845 }
846 }
847
848 // detect the enclosure used in the CSV
849 $bestEnclosure = '"';
850 $maxQuotedTotal = 0;
851
852 // test all the supported enclosures
853 foreach ($enclosures as $enc)
854 {
855 $quotedTotal = 0;
856
857 // count the total number of columns that properly use the current enclosure
858 foreach ($lines as $line)
859 {
860 // extract the columns for the current line
861 $fields = array_map('trim', explode($bestSeparator, $line));
862
863 // preserve only the columns that actually start and ends with the current enclosure
864 $quoted = array_filter($fields, function($f) use ($enc) {
865 return str_starts_with($f, $enc) && str_ends_with($f, $enc);
866 });
867
868 $quotedTotal += count($quoted);
869 }
870
871 // make sure the total number of occurrences is higher than the one calculated
872 // for the previously tested enclosure
873 if ($quotedTotal > $maxQuotedTotal)
874 {
875 // a new optimal enclosure has been found
876 $bestEnclosure = $enc;
877 $maxQuotedTotal = $quotedTotal;
878 }
879 }
880
881 return [$bestSeparator, $bestEnclosure];
882 }
883 }
884