Skip to content

Commit 34f994f

Browse files
feat(import): add native XLSX import support (#1281)
Add Visualizer_Source_Xlsx for local .xlsx file parsing via OpenSpout
1 parent e61e5dd commit 34f994f

7 files changed

Lines changed: 542 additions & 24 deletions

File tree

classes/Visualizer/Module/Chart.php

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,78 @@ public function renderFlattrScript() {
10121012
*
10131013
* @since 3.2.0
10141014
*/
1015+
/**
1016+
* Determines whether a remote URL serves an XLSX file.
1017+
*
1018+
* Used as a fallback when the URL path has no recognisable file extension
1019+
* (e.g. SharePoint, signed S3 URLs, or "download?id=…" endpoints).
1020+
*
1021+
* Uses wp_safe_remote_get() to block requests to private/loopback addresses,
1022+
* and streams the response to a temp file so no body data is held in memory
1023+
* regardless of whether the server honours the Range header.
1024+
*
1025+
* The check relies on the ZIP magic number (PK\x03\x04) that every XLSX
1026+
* file begins with, making it immune to misleading Content-Type headers
1027+
* such as application/octet-stream. Content-Type is used as a last-resort
1028+
* fallback only when the temp file is empty (e.g. a HEAD-only server).
1029+
*
1030+
* @access private
1031+
* @param string $url The remote URL to probe.
1032+
* @return bool TRUE if the file appears to be XLSX, FALSE otherwise.
1033+
*/
1034+
private static function _url_is_xlsx( $url ) {
1035+
$tmpfile = wp_tempnam( 'visualizer_xlsx_probe' );
1036+
if ( ! $tmpfile ) {
1037+
return false;
1038+
}
1039+
1040+
$response = wp_safe_remote_get(
1041+
$url,
1042+
array(
1043+
'timeout' => 10,
1044+
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ),
1045+
'headers' => array( 'Range' => 'bytes=0-3' ),
1046+
'stream' => true,
1047+
'filename' => $tmpfile,
1048+
)
1049+
);
1050+
1051+
if ( is_wp_error( $response ) ) {
1052+
@unlink( $tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
1053+
return false;
1054+
}
1055+
1056+
$magic = '';
1057+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
1058+
$fh = @fopen( $tmpfile, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
1059+
if ( $fh ) {
1060+
$magic = fread( $fh, 4 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
1061+
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
1062+
}
1063+
@unlink( $tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
1064+
1065+
if ( strlen( $magic ) >= 4 ) {
1066+
// XLSX (and all ZIP-based Office formats) start with PK\x03\x04.
1067+
return $magic === "PK\x03\x04";
1068+
}
1069+
1070+
// Last resort: server returned an empty body (e.g. ignored Range and
1071+
// returned only headers). Check Content-Type from the same response.
1072+
// application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
1073+
return false !== strpos(
1074+
wp_remote_retrieve_header( $response, 'content-type' ),
1075+
'spreadsheetml'
1076+
);
1077+
}
1078+
1079+
/**
1080+
* Parses a raw CSV string or editor payload and returns a source object.
1081+
*
1082+
* @access private
1083+
* @param string $data The raw CSV data string.
1084+
* @param string $editor_type The editor type ('text' or 'tabular').
1085+
* @return Visualizer_Source|null The populated source object, or null on failure.
1086+
*/
10151087
private function handleCSVasString( $data, $editor_type ) {
10161088
$source = null;
10171089

@@ -1211,12 +1283,22 @@ public function uploadData() {
12111283
$remote_data = wp_http_validate_url( $_POST['remote_data'] );
12121284
}
12131285
if ( false !== $remote_data ) {
1214-
$source = new Visualizer_Source_Csv_Remote( $remote_data );
1286+
$remote_ext = strtolower( pathinfo( parse_url( $remote_data, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
1287+
if ( 'xlsx' === $remote_ext || ( 'csv' !== $remote_ext && self::_url_is_xlsx( $remote_data ) ) ) {
1288+
$source = new Visualizer_Source_Xlsx_Remote( $remote_data );
1289+
} else {
1290+
$source = new Visualizer_Source_Csv_Remote( $remote_data );
1291+
}
12151292
if ( isset( $_POST['vz-import-time'] ) ) {
12161293
apply_filters( 'visualizer_pro_chart_schedule', $chart_id, $remote_data, $_POST['vz-import-time'] );
12171294
}
12181295
} elseif ( isset( $_FILES['local_data'] ) && $_FILES['local_data']['error'] === 0 ) {
1219-
$source = new Visualizer_Source_Csv( $_FILES['local_data']['tmp_name'] );
1296+
$local_ext = strtolower( pathinfo( isset( $_FILES['local_data']['name'] ) ? $_FILES['local_data']['name'] : '', PATHINFO_EXTENSION ) );
1297+
if ( 'xlsx' === $local_ext ) {
1298+
$source = new Visualizer_Source_Xlsx( $_FILES['local_data']['tmp_name'] );
1299+
} else {
1300+
$source = new Visualizer_Source_Csv( $_FILES['local_data']['tmp_name'] );
1301+
}
12201302
} elseif ( isset( $_POST['chart_data'] ) && strlen( $_POST['chart_data'] ) > 0 ) {
12211303
$source = $this->handleCSVasString( $_POST['chart_data'], $_POST['editor-type'] );
12221304
update_post_meta( $chart_id, Visualizer_Plugin::CF_EDITOR, $_POST['editor-type'] );

classes/Visualizer/Render/Layout.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,7 @@ public static function _renderTabBasic( $args ) {
810810
<h2 class="viz-group-title viz-sub-group visualizer-src-tab"><?php _e( 'Import data from file', 'visualizer' ); ?><span class="dashicons dashicons-lock"></span></h2>
811811
<div class="viz-group-content">
812812
<div>
813-
<p class="viz-group-description"><?php esc_html_e( 'Select and upload your data CSV file here. The first row of the CSV file should contain the column headings. The second one should contain series type (string, number, boolean, date, datetime, timeofday).', 'visualizer' ); ?></p>
813+
<p class="viz-group-description"><?php esc_html_e( 'Select and upload your data file here. Supported formats: CSV, XLSX. The first row should contain the column headings. The second row should contain the series type (string, number, boolean, date, datetime, timeofday).', 'visualizer' ); ?></p>
814814
<p class="viz-group-description viz-info-msg">
815815
<b>
816816
<?php
@@ -826,7 +826,7 @@ public static function _renderTabBasic( $args ) {
826826
target="thehole" enctype="multipart/form-data">
827827
<input type="hidden" id="remote-data" name="remote_data">
828828
<div class="">
829-
<input type="file" id="csv-file" name="local_data">
829+
<input type="file" id="csv-file" name="local_data" accept=".csv,.xlsx">
830830
</div>
831831
<input type="button" class="button button-primary" id="vz-import-file"
832832
value="<?php _e( 'Import', 'visualizer' ); ?>">
@@ -841,14 +841,14 @@ public static function _renderTabBasic( $args ) {
841841
<ul class="viz-group-content">
842842
<!-- import from csv url -->
843843
<li class="viz-subsection">
844-
<span class="viz-section-title"><?php _e( 'Import from CSV', 'visualizer' ); ?></span>
844+
<span class="viz-section-title"><?php _e( 'Import from CSV / XLSX', 'visualizer' ); ?></span>
845845
<div class="only-pro-anchor">
846846
<div class="viz-section-items section-items">
847847
<p class="viz-group-description">
848848
<?php
849849
echo sprintf(
850850
// translators: %1$s - HTML link tag, %2$s - HTML closing link tag.
851-
__( 'You can use this to import data from a remote CSV file or %1$sGoogle Spreadsheet%2$s.', 'visualizer' ),
851+
__( 'You can use this to import data from a remote CSV or Excel (XLSX) file, or %1$sGoogle Spreadsheet%2$s.', 'visualizer' ),
852852
'<a href="https://docs.themeisle.com/article/607-how-can-i-populate-data-from-google-spreadsheet" target="_blank" >',
853853
'</a>'
854854
);
@@ -868,7 +868,7 @@ public static function _renderTabBasic( $args ) {
868868
<form id="vz-one-time-import" action="<?php echo $upload_link; ?>" method="post"
869869
target="thehole" enctype="multipart/form-data">
870870
<div class="remote-file-section">
871-
<input type="url" id="vz-schedule-url" name="remote_data" value="<?php echo esc_attr( get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_URL, true ) ); ?>" placeholder="<?php esc_html_e( 'Please enter the URL of CSV file', 'visualizer' ); ?>" class="visualizer-input visualizer-remote-url">
871+
<input type="url" id="vz-schedule-url" name="remote_data" value="<?php echo esc_attr( get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_URL, true ) ); ?>" placeholder="<?php esc_html_e( 'Please enter the URL of a CSV or XLSX file', 'visualizer' ); ?>" class="visualizer-input visualizer-remote-url">
872872
</div>
873873
<select name="vz-import-time" id="vz-import-time" class="visualizer-select">
874874
<?php

classes/Visualizer/Source/Xlsx.php

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
/**
3+
* Source manager for local XLSX files.
4+
*
5+
* Reads the first sheet of an XLSX file using the OpenSpout library,
6+
* which is already bundled as a dependency of this plugin.
7+
* Legacy .xls (BIFF) files are not supported; OpenSpout's XLSX reader
8+
* only handles the Office Open XML (.xlsx) format.
9+
*
10+
* Expected sheet layout (same convention as CSV import):
11+
* Row 1 – column labels
12+
* Row 2 – column types (string, number, boolean, date, datetime, timeofday)
13+
* Row 3+ – data rows
14+
*
15+
* @category Visualizer
16+
* @package Source
17+
*
18+
* @since 3.11.0
19+
*/
20+
class Visualizer_Source_Xlsx extends Visualizer_Source {
21+
22+
/**
23+
* The path to the XLSX file.
24+
*
25+
* @access protected
26+
* @var string
27+
*/
28+
protected $_filename;
29+
30+
/**
31+
* Constructor.
32+
*
33+
* @access public
34+
* @param string $filename Path to the XLSX file.
35+
*/
36+
public function __construct( $filename = '' ) {
37+
$this->_filename = trim( (string) $filename );
38+
}
39+
40+
/**
41+
* Fetches information from the XLSX file and builds the series/data arrays.
42+
*
43+
* @access public
44+
* @return boolean TRUE on success, FALSE on failure.
45+
*/
46+
public function fetch() {
47+
if ( empty( $this->_filename ) ) {
48+
$this->_error = esc_html__( 'No file provided. Please try again.', 'visualizer' );
49+
return false;
50+
}
51+
52+
// Ensure the OpenSpout autoloader is available.
53+
$vendor_file = VISUALIZER_ABSPATH . 'vendor/autoload.php';
54+
if ( is_readable( $vendor_file ) ) {
55+
include_once $vendor_file;
56+
}
57+
58+
if ( ! class_exists( 'OpenSpout\Reader\Common\Creator\ReaderEntityFactory' ) ) {
59+
$this->_error = esc_html__( 'The OpenSpout library is required to import XLSX files but could not be found. Please contact support.', 'visualizer' );
60+
return false;
61+
}
62+
63+
$reader = \OpenSpout\Reader\Common\Creator\ReaderEntityFactory::createXLSXReader();
64+
try {
65+
$reader->open( $this->_get_file_path() );
66+
67+
$all_rows = array();
68+
foreach ( $reader->getSheetIterator() as $sheet ) {
69+
foreach ( $sheet->getRowIterator() as $row ) {
70+
$row_data = array();
71+
foreach ( $row->getCells() as $cell ) {
72+
$value = $cell->getValue();
73+
// Convert non-string scalars to string for uniform handling;
74+
// _normalizeData() will cast them to the correct type later.
75+
$row_data[] = is_null( $value ) ? null : (string) $value;
76+
}
77+
$all_rows[] = $row_data;
78+
}
79+
break; // Only read the first sheet.
80+
}
81+
} catch ( \Exception $e ) {
82+
$reader->close();
83+
$this->_error = sprintf(
84+
/* translators: %s - the exception message. */
85+
esc_html__( 'Could not read the XLSX file: %s', 'visualizer' ),
86+
$e->getMessage()
87+
);
88+
return false;
89+
}
90+
91+
$reader->close();
92+
93+
if ( count( $all_rows ) < 2 ) {
94+
$this->_error = esc_html__( 'File should have a heading row (1st row) and a data type row (2nd row). Please try again.', 'visualizer' );
95+
return false;
96+
}
97+
98+
$labels = array_filter( $all_rows[0] );
99+
$types = array_filter( $all_rows[1] );
100+
101+
if ( ! $labels || ! $types ) {
102+
$this->_error = esc_html__( 'File should have a heading row (1st row) and a data type row (2nd row). Please try again.', 'visualizer' );
103+
return false;
104+
}
105+
106+
$types = array_map( 'trim', $types );
107+
if ( ! self::_validateTypes( $types ) ) {
108+
$this->_error = esc_html__( 'Invalid data types detected in the data type row (2nd row). Please try again.', 'visualizer' );
109+
return false;
110+
}
111+
112+
// Build the series array from row 1 (labels) and row 2 (types).
113+
$label_values = $all_rows[0];
114+
$type_values = $all_rows[1];
115+
$col_count = count( $label_values );
116+
117+
for ( $i = 0; $i < $col_count; $i++ ) {
118+
$default_type = ( $i === 0 ) ? 'string' : 'number';
119+
$label = isset( $label_values[ $i ] ) ? $this->toUTF8( (string) $label_values[ $i ] ) : '';
120+
$type = isset( $type_values[ $i ] ) && ! empty( $type_values[ $i ] ) ? trim( $type_values[ $i ] ) : $default_type;
121+
122+
$this->_series[] = array(
123+
'label' => sanitize_text_field( wp_strip_all_tags( $label ) ),
124+
'type' => $type,
125+
);
126+
}
127+
128+
// Parse data rows (row 3 onwards).
129+
for ( $r = 2, $total = count( $all_rows ); $r < $total; $r++ ) {
130+
$this->_data[] = $this->_normalizeData( $all_rows[ $r ] );
131+
}
132+
133+
return true;
134+
}
135+
136+
/**
137+
* Returns the file path to open with the reader.
138+
* Subclasses may override this to supply a locally-downloaded copy.
139+
*
140+
* @access protected
141+
* @return string
142+
*/
143+
protected function _get_file_path() {
144+
return $this->_filename;
145+
}
146+
147+
/**
148+
* Returns the source name.
149+
*
150+
* @access public
151+
* @return string
152+
*/
153+
public function getSourceName() {
154+
return __CLASS__;
155+
}
156+
}

0 commit comments

Comments
 (0)