Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions classes/Visualizer/Module/Chart.php
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,78 @@ public function renderFlattrScript() {
*
* @since 3.2.0
*/
/**
* Determines whether a remote URL serves an XLSX file.
*
* Used as a fallback when the URL path has no recognisable file extension
* (e.g. SharePoint, signed S3 URLs, or "download?id=…" endpoints).
*
* Uses wp_safe_remote_get() to block requests to private/loopback addresses,
* and streams the response to a temp file so no body data is held in memory
* regardless of whether the server honours the Range header.
*
* The check relies on the ZIP magic number (PK\x03\x04) that every XLSX
* file begins with, making it immune to misleading Content-Type headers
* such as application/octet-stream. Content-Type is used as a last-resort
* fallback only when the temp file is empty (e.g. a HEAD-only server).
*
* @access private
* @param string $url The remote URL to probe.
* @return bool TRUE if the file appears to be XLSX, FALSE otherwise.
*/
private static function _url_is_xlsx( $url ) {
$tmpfile = wp_tempnam( 'visualizer_xlsx_probe' );
if ( ! $tmpfile ) {
return false;
}

$response = wp_safe_remote_get(
$url,
array(
'timeout' => 10,
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ),
'headers' => array( 'Range' => 'bytes=0-3' ),
'stream' => true,
'filename' => $tmpfile,
)
);

if ( is_wp_error( $response ) ) {
@unlink( $tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return false;
}

$magic = '';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
$fh = @fopen( $tmpfile, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( $fh ) {
$magic = fread( $fh, 4 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
}
@unlink( $tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors

if ( strlen( $magic ) >= 4 ) {
// XLSX (and all ZIP-based Office formats) start with PK\x03\x04.
return $magic === "PK\x03\x04";
}

// Last resort: server returned an empty body (e.g. ignored Range and
// returned only headers). Check Content-Type from the same response.
// application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
return false !== strpos(
wp_remote_retrieve_header( $response, 'content-type' ),
'spreadsheetml'
);
}

/**
* Parses a raw CSV string or editor payload and returns a source object.
*
* @access private
* @param string $data The raw CSV data string.
* @param string $editor_type The editor type ('text' or 'tabular').
* @return Visualizer_Source|null The populated source object, or null on failure.
*/
private function handleCSVasString( $data, $editor_type ) {
$source = null;

Expand Down Expand Up @@ -1209,12 +1281,22 @@ public function uploadData() {
$remote_data = wp_http_validate_url( $_POST['remote_data'] );
}
if ( false !== $remote_data ) {
$source = new Visualizer_Source_Csv_Remote( $remote_data );
$remote_ext = strtolower( pathinfo( parse_url( $remote_data, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
if ( 'xlsx' === $remote_ext || ( 'csv' !== $remote_ext && self::_url_is_xlsx( $remote_data ) ) ) {
$source = new Visualizer_Source_Xlsx_Remote( $remote_data );
} else {
$source = new Visualizer_Source_Csv_Remote( $remote_data );
}
if ( isset( $_POST['vz-import-time'] ) ) {
apply_filters( 'visualizer_pro_chart_schedule', $chart_id, $remote_data, $_POST['vz-import-time'] );
}
} elseif ( isset( $_FILES['local_data'] ) && $_FILES['local_data']['error'] === 0 ) {
$source = new Visualizer_Source_Csv( $_FILES['local_data']['tmp_name'] );
$local_ext = strtolower( pathinfo( isset( $_FILES['local_data']['name'] ) ? $_FILES['local_data']['name'] : '', PATHINFO_EXTENSION ) );
if ( 'xlsx' === $local_ext ) {
$source = new Visualizer_Source_Xlsx( $_FILES['local_data']['tmp_name'] );
} else {
$source = new Visualizer_Source_Csv( $_FILES['local_data']['tmp_name'] );
}
} elseif ( isset( $_POST['chart_data'] ) && strlen( $_POST['chart_data'] ) > 0 ) {
$source = $this->handleCSVasString( $_POST['chart_data'], $_POST['editor-type'] );
update_post_meta( $chart_id, Visualizer_Plugin::CF_EDITOR, $_POST['editor-type'] );
Expand Down
10 changes: 5 additions & 5 deletions classes/Visualizer/Render/Layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ public static function _renderTabBasic( $args ) {
<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>
<div class="viz-group-content">
<div>
<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>
<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>
<p class="viz-group-description viz-info-msg">
<b>
<?php
Expand All @@ -826,7 +826,7 @@ public static function _renderTabBasic( $args ) {
target="thehole" enctype="multipart/form-data">
<input type="hidden" id="remote-data" name="remote_data">
<div class="">
<input type="file" id="csv-file" name="local_data">
<input type="file" id="csv-file" name="local_data" accept=".csv,.xlsx">
</div>
<input type="button" class="button button-primary" id="vz-import-file"
value="<?php _e( 'Import', 'visualizer' ); ?>">
Expand All @@ -841,14 +841,14 @@ public static function _renderTabBasic( $args ) {
<ul class="viz-group-content">
<!-- import from csv url -->
<li class="viz-subsection">
<span class="viz-section-title"><?php _e( 'Import from CSV', 'visualizer' ); ?></span>
<span class="viz-section-title"><?php _e( 'Import from CSV / XLSX', 'visualizer' ); ?></span>
<div class="only-pro-anchor">
<div class="viz-section-items section-items">
<p class="viz-group-description">
<?php
echo sprintf(
// translators: %1$s - HTML link tag, %2$s - HTML closing link tag.
__( 'You can use this to import data from a remote CSV file or %1$sGoogle Spreadsheet%2$s.', 'visualizer' ),
__( 'You can use this to import data from a remote CSV or Excel (XLSX) file, or %1$sGoogle Spreadsheet%2$s.', 'visualizer' ),
'<a href="https://docs.themeisle.com/article/607-how-can-i-populate-data-from-google-spreadsheet" target="_blank" >',
'</a>'
);
Expand All @@ -868,7 +868,7 @@ public static function _renderTabBasic( $args ) {
<form id="vz-one-time-import" action="<?php echo $upload_link; ?>" method="post"
target="thehole" enctype="multipart/form-data">
<div class="remote-file-section">
<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">
<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">
</div>
<select name="vz-import-time" id="vz-import-time" class="visualizer-select">
<?php
Expand Down
156 changes: 156 additions & 0 deletions classes/Visualizer/Source/Xlsx.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php
/**
* Source manager for local XLSX files.
*
* Reads the first sheet of an XLSX file using the OpenSpout library,
* which is already bundled as a dependency of this plugin.
* Legacy .xls (BIFF) files are not supported; OpenSpout's XLSX reader
* only handles the Office Open XML (.xlsx) format.
*
* Expected sheet layout (same convention as CSV import):
* Row 1 – column labels
* Row 2 – column types (string, number, boolean, date, datetime, timeofday)
* Row 3+ – data rows
*
* @category Visualizer
* @package Source
*
* @since 3.11.0
*/
class Visualizer_Source_Xlsx extends Visualizer_Source {

/**
* The path to the XLSX file.
*
* @access protected
* @var string
*/
protected $_filename;

/**
* Constructor.
*
* @access public
* @param string $filename Path to the XLSX file.
*/
public function __construct( $filename = '' ) {
$this->_filename = trim( (string) $filename );
}

/**
* Fetches information from the XLSX file and builds the series/data arrays.
*
* @access public
* @return boolean TRUE on success, FALSE on failure.
*/
public function fetch() {
if ( empty( $this->_filename ) ) {
$this->_error = esc_html__( 'No file provided. Please try again.', 'visualizer' );
return false;
}

// Ensure the OpenSpout autoloader is available.
$vendor_file = VISUALIZER_ABSPATH . 'vendor/autoload.php';
if ( is_readable( $vendor_file ) ) {
include_once $vendor_file;
}

if ( ! class_exists( 'OpenSpout\Reader\Common\Creator\ReaderEntityFactory' ) ) {
$this->_error = esc_html__( 'The OpenSpout library is required to import XLSX files but could not be found. Please contact support.', 'visualizer' );
return false;
}

$reader = \OpenSpout\Reader\Common\Creator\ReaderEntityFactory::createXLSXReader();
try {
$reader->open( $this->_get_file_path() );

$all_rows = array();
foreach ( $reader->getSheetIterator() as $sheet ) {
foreach ( $sheet->getRowIterator() as $row ) {
$row_data = array();
foreach ( $row->getCells() as $cell ) {
$value = $cell->getValue();
// Convert non-string scalars to string for uniform handling;
// _normalizeData() will cast them to the correct type later.
$row_data[] = is_null( $value ) ? null : (string) $value;
}
$all_rows[] = $row_data;
}
break; // Only read the first sheet.
}
} catch ( \Exception $e ) {
$reader->close();
$this->_error = sprintf(
/* translators: %s - the exception message. */
esc_html__( 'Could not read the XLSX file: %s', 'visualizer' ),
$e->getMessage()
);
return false;
}

$reader->close();

if ( count( $all_rows ) < 2 ) {
$this->_error = esc_html__( 'File should have a heading row (1st row) and a data type row (2nd row). Please try again.', 'visualizer' );
return false;
}

$labels = array_filter( $all_rows[0] );
$types = array_filter( $all_rows[1] );

if ( ! $labels || ! $types ) {
$this->_error = esc_html__( 'File should have a heading row (1st row) and a data type row (2nd row). Please try again.', 'visualizer' );
return false;
}

$types = array_map( 'trim', $types );
if ( ! self::_validateTypes( $types ) ) {
$this->_error = esc_html__( 'Invalid data types detected in the data type row (2nd row). Please try again.', 'visualizer' );
return false;
}

// Build the series array from row 1 (labels) and row 2 (types).
$label_values = $all_rows[0];
$type_values = $all_rows[1];
$col_count = count( $label_values );

for ( $i = 0; $i < $col_count; $i++ ) {
$default_type = ( $i === 0 ) ? 'string' : 'number';
$label = isset( $label_values[ $i ] ) ? $this->toUTF8( (string) $label_values[ $i ] ) : '';
$type = isset( $type_values[ $i ] ) && ! empty( $type_values[ $i ] ) ? trim( $type_values[ $i ] ) : $default_type;

$this->_series[] = array(
'label' => sanitize_text_field( wp_strip_all_tags( $label ) ),
'type' => $type,
);
}

// Parse data rows (row 3 onwards).
for ( $r = 2, $total = count( $all_rows ); $r < $total; $r++ ) {
$this->_data[] = $this->_normalizeData( $all_rows[ $r ] );
}

return true;
}

/**
* Returns the file path to open with the reader.
* Subclasses may override this to supply a locally-downloaded copy.
*
* @access protected
* @return string
*/
protected function _get_file_path() {
return $this->_filename;
}

/**
* Returns the source name.
*
* @access public
* @return string
*/
public function getSourceName() {
return __CLASS__;
}
}
Loading
Loading