Files
2025-12-19 19:04:57 -08:00

880 lines
26 KiB
PHP

<?php
/**
* @brief mycharts
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 09 Dec 2022
*/
namespace IPS\core\modules\admin\overview;
use DateInterval;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Exception;
use InvalidArgumentException;
use IPS\Application;
use IPS\core\Statistics\Chart;
use IPS\DateTime;
use IPS\Db;
use IPS\Dispatcher\Controller;
use IPS\Helpers\Chart\Dynamic;
use IPS\Helpers\Form;
use IPS\Helpers\Form\DateRange;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use Throwable;
use function array_fill;
use function array_key_exists;
use function array_merge;
use function array_values;
use function count;
use function defined;
use function explode;
use function fclose;
use function fopen;
use function fputcsv;
use function in_array;
use function is_array;
use function is_callable;
use function is_iterable;
use function is_numeric;
use function is_string;
use function iterator_to_array;
use function ksort;
use function max;
use function str_replace;
use function tempnam;
use function time;
use function trim;
use const IPS\TEMP_DIRECTORY;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* mycharts
*/
class mycharts extends Controller
{
/**
* @brief Has been CSRF-protected
*/
public static bool $csrfProtected = TRUE;
/**
* @brief Allow MySQL RW separation for efficiency
*/
public static bool $allowRWSeparation = TRUE;
/**
* @var "charts"|"blocks"
*/
protected string $activeTab = "charts";
/**
* The report id for this request
* @var int|null
*/
protected int|null $reportId = null;
protected bool $communityhealth = false;
/**
* @var string[]
*/
protected array $tabs = [
"charts" => "statsreports_saved_charts",
"blocks" => "statsreports_saved_blocks",
];
/**
* Get the report for this request
*
* @return array|null
*/
protected function _getReport()
{
static $report = false;
if ( $this->reportId and $report === false )
{
try
{
$report = Db::i()->select( "*", "core_reports", [['id=?', $this->reportId]] )->first();
}
catch ( \UnderflowException $e )
{
$report = null;
}
}
else if ( $report === false )
{
$report = null;
}
return $report;
}
protected ?array $chartRow= null;
/**
* Community health shortcut from the menu system
*/
protected function communityhealth() : void
{
if ( !Bridge::i()->featureIsEnabled( 'community_health_stats' ) )
{
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
}
Request::i()->communityhealth = '1';
$this->communityhealth = true;
$this->manage();
}
/**
* @return void
* @throws Exception
*/
public function execute() : void
{
if ( !Request::i()->communityhealth and Request::i()->chartId and Bridge::i()->featureIsEnabled( 'community_health_stats' ) )
{
$allCharts = Bridge::i()->getCommunityHealthCharts();
foreach( $allCharts as $data )
{
if ( $data['chart_id'] == Request::i()->chartId )
{
$this->chartRow = $data;
break;
}
}
if ( $this->chartRow )
{
Request::i()->communityhealth = '1';
}
}
if ( Request::i()->communityhealth )
{
if ( !Bridge::i()->featureIsEnabled( "community_health_stats" ) )
{
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
}
$this->communityhealth = true;
$this->activeTab = "charts";
$this->reportId = null;
}
else
{
$this->activeTab = isset( $this->tabs[ Request::i()->tab ] ) ? Request::i()->tab : "charts";
$this->reportId = is_numeric( Request::i()->report_id ) ? (int)Request::i()->report_id : 0;
$this->reportId = $this->reportId > 0 ? $this->reportId : null;
if ( $this->reportId and !$this->_getReport() )
{
Output::i()->redirect( Url::internal( (string)Request::i()->url() )->setQueryString( [ "report_id" => "0" ] ) );
}
}
parent::execute();
}
/**
* Manage
*
* @return void
*/
protected function manage() : void
{
$report = $this->_getReport();
Output::i()->title = $report ? $report['report_title'] : Member::loggedIn()->loggedIn()->language()->addToStack( $this->communityhealth ? "communityhealth" : "menu__core_overview_mycharts" );
// Have to generate the form regardless of ajax so the values can be saved
$form = Chart::getBlockDateRangeForm( $report ? $report['id'] : null );
if ( !Request::i()->isAjax() )
{
Output::i()->output = $form;
}
if ( $this->activeTab === "charts" )
{
$charts = [];
foreach( Chart::getChartsForMember( Url::internal( "app=core&module=overview&controller=mycharts" . ( $this->communityhealth ? '&communityhealth=1' : '' ) ), TRUE ) AS $id )
{
$charts[] = $id;
}
if ( !\count( $charts ) )
{
$content = Theme::i()->getTemplate( 'stats')->mychartsEmpty();
}
else
{
$content = Theme::i()->getTemplate( 'stats' )->mycharts( $charts );
}
}
else
{
$content = Theme::i()->getTemplate( "reports", "core", "admin" )->savedStatBlocks();
}
if ( Request::i()->isAjax() )
{
Output::i()->sendOutput( $content );
}
$this->_addSidebarButtons();
Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_stats.js', 'core' ) );
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/statistics.css', 'core', 'admin' ) );
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/reports.css', 'core', 'admin' ) );
$url = Url::internal( "app=core&module=overview&controller=mycharts" );
if ( $this->communityhealth )
{
$url = $url->setQueryString( [ 'do' => 'communityhealth' ] );
}
else if ( $this->reportId )
{
$url = $url->setQueryString( [ "report_id" => $this->reportId ] );
}
if ( $this->communityhealth )
{
Output::i()->output .= Theme::i()->getTemplate( "global", "core", "admin" )->tabs( ['charts' => 'statsreports_saved_charts'], 'charts', $content, $url );
return;
}
Output::i()->output .= Theme::i()->getTemplate( "global", "core", "admin" )->tabs( $this->tabs, $this->activeTab, $content, $url );
}
/**
* Get chart
*
* @return void
*/
public function getChart() : void
{
if ( !isset( Request::i()->chartId ) )
{
if ( Request::i()->isAjax() )
{
Output::i()->output = '';
}
else
{
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
}
}
try
{
/* We first get the date range form to load the chart if it exists */
$report = $this->_getReport();
Chart::getBlockDateRangeForm( $report ? $report['id'] : null );
$chart = Chart::constructMemberChartFromData( $this->chartRow ?: Request::i()->chartId, Url::internal( "app=core&module=overview&controller=mycharts&do=getChart&chartId=" . Request::i()->chartId ) );
$chart->options['height'] = '300px'; // This chart is displayed in a grid with other charts, so we want them all to have the same height
Output::i()->output = (string) $chart;
}
catch( Throwable )
{
if ( Request::i()->isAjax() )
{
Output::i()->output = '';
}
else
{
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
}
}
}
/**
* Add the export and report selector sidebar buttons
*
* @return void
*/
protected function _addSidebarButtons()
{
if ( $this->_getReport() or Bridge::i()->featureIsEnabled( 'analytics_full' ) or Db::i()->select( "count(*) as cnt", 'core_reports' )->first() )
{
Output::i()->sidebar['actions']['select_report'] = array(
'primary' => false,
'icon' => 'area-chart',
'title' => 'statsreports_select_report',
'id' => 'elStatsSelectReport',
'menu'=> $this->getReportSelector(),
'link' => '#'
);
}
Output::i()->sidebar['actions']['download'] = array(
'primary' => true,
'icon' => 'download',
'title' => 'statsreports_download',
'link' => Url::internal( 'app=core&module=overview&controller=mycharts&do=download' )->setQueryString( [ "report_id" => $this->reportId ?: 0, "tab" => $this->activeTab ]),
'data' => array( 'ipstooltip' => '', 'ipstooltip-label' => Member::loggedIn()->language()->addToStack( 'statsreports_download_desc'), 'controller' => 'core.admin.stats.downloadButton' )
);
}
/**
* Get the dropdown to pick a report
*
* @return array
*/
protected function getReportSelector() : array
{
$return = [
'main' => [
'icon' => 'earth-americas',
'title' => 'statsreports_report_maindash',
'link' => Url::internal( "app=core&moduel=overview&controller=mycharts&tab=" . $this->activeTab )
]
];
if( Bridge::i()->featureIsEnabled( 'community_health_stats' ) )
{
$return[ 'health' ] = [
'icon' => 'heart-pulse',
'title' => 'communityhealth',
'link' => Url::internal( "app=core&module=overview&controller=mycharts&communityhealth=1" )
];
}
foreach( iterator_to_array( Db::i()->select( '*', 'core_reports', order: "report_title ASC" ) ) as $row )
{
$return[ 'report_' . $row['id'] ] = [
'icon' => 'bookmark',
'link' => Url::internal( "app=core&module=overview&controller=mycharts&report_id={$row['id']}&tab={$this->activeTab}" ),
'title' => $row['report_title']
];
}
return $return;
}
/**
* @return void
*/
protected function saveBlock()
{
$block = Request::i()->block;
$app = Request::i()->blockapp;
$subblock = Request::i()->subblock;
if ( !$block || !$app || !$subblock )
{
Output::i()->json( "No block, sub-block or app specified in the request", 400 );
}
$savedBlock = null;
if ( $savedId = Request::i()->saved_block_id )
{
try
{
$savedBlock = Db::i()->select( '*', 'core_saved_overview_stats', [ 'id=?', $savedId] )->first();
}
catch ( \UnderflowException )
{
Output::i()->json( "Cannot find block with id {$savedId}", 404 );
}
}
$defaultTitle = null;
if ( isset( $savedBlock['stat_title'] ) )
{
$defaultTitle = $savedBlock['stat_title'];
}
else
{
try
{
$application = Application::load( $app );
$extension = null;
foreach ( $application->extensions( 'core', 'OverviewStatistics', true ) as $key => $_extension )
{
if ( $key === $block )
{
$extension = $_extension;
break;
}
}
if ( !$extension )
{
throw new OutOfRangeException( "The OverviewStatistics plugin {$block} does not exist for the app {$app}" );
}
if ( !in_array( $subblock, $extension->getBlocks() ) )
{
throw new OutOfRangeException( "Block {$subblock} not defined for this overview stats plugin" );
}
if ( ( $details = $extension->getBlockDetails( $subblock ) ) and isset( $details['title'] ) )
{
try
{
$defaultTitle = Member::loggedIn()->language()->addToStack( $details['title'] );
Member::loggedIn()->language()->parseOutputForDisplay( $defaultTitle );
}
catch ( Exception ){}
}
}
catch ( Exception )
{
Output::i()->json( "The block or subblock could not be found", 404 );
}
}
$form = new Form();
$form->class = 'ipsForm--vertical';
$form->add(new Text( "statsreports_saved_block_title", $defaultTitle, true ));
if ( !is_array( $savedBlock ) )
{
$reports = iterator_to_array( Db::i()->select( "*", "core_reports" ) );
$reportOptions = [];
foreach( $reports as $row )
{
$reportOptions["report_" . $row['id']] = $row['report_title'];
}
$form->add( new YesNo( "statsreports_show_all_members", true, false ) );
$form->add( new YesNo( "statsreports_include_in_report", false, false, [ "togglesOn" => [ "statsreports_report_select", empty($reportOptions) ? "statsreports_report_new_title" : "" ] ] ) );
// if there are saved reports, allow user to select one of them
if ( !empty( $reportOptions ) )
{
// todo an autocomplete field is probably a better UX when there could be dozens of reports saved
$form->add( new Select( "statsreports_report", @$reports[0] ? 'report_'.$reports[0]['id'] : false, false, [
"unlimited" => false,
"unlimitedLang" => "statsreports_report_new",
"unlimitedToggles" => [
'statsreports_report_new_title'
],
"options" => $reportOptions,
], id: 'statsreports_report_select' ) );
}
$form->add( new Text( "statsreports_report_new_title", null, false, customValidationCode: (function( $value ) use ( $reportOptions ) {
if ( ( empty( $reportOptions ) OR ( isset( Request::i()->statsreports_report_unlimited ) ) ) and !empty( (int) Request::i()->statsreports_include_in_report_checkbox ) and ( !$value OR strlen( $value ) < 5 ) )
{
throw new InvalidArgumentException( Member::loggedIn()->language()->addToStack( "statsreports_no_title_error" ) );
}
else if ( !empty( $reportOptions ) and in_array( $value, array_values( $reportOptions ) ) )
{
throw new InvalidArgumentException( Member::loggedIn()->language()->addToStack( 'statsreports_title_in_use_error' ) );
}
}), id: "statsreports_report_new_title" ) );
}
if ( $values = $form->values() )
{
// first, is there a report this block is being saved to?
$reportID = null;
if ( @$values['statsreports_include_in_report'] )
{
if ( isset( $values['statsreports_report'] ) and $values['statsreports_report'] )
{
$reportID = (int) explode( '_', $values['statsreports_report'], 2 )[1];
}
else if ( ( empty( $reportOptions ) and !empty( $values['statsreports_report_new_title'] ) ) or @$values['statsreports_report'] === false )
{
$title = $values['statsreports_report_new_title'];
$reportID = Db::i()->insert( "core_reports", [ "report_title" => $title ] );
}
}
$insert = [
"stat_app" => $app,
"stat_extension" => $block,
"stat_subblock" => $subblock,
"stat_title" => $values['statsreports_saved_block_title'],
"stat_report_id" => $reportID,
];
if ( is_array( $savedBlock ) )
{
$insert['id'] = $savedBlock['id'];
}
else
{
$insert['stat_member'] = $values['statsreports_show_all_members'] ? null: Member::loggedIn()->member_id;
}
Db::i()->insert( "core_saved_overview_stats", $insert, true );
if ( Request::i()->isAjax() or Request::i()->frommenu )
{
Output::i()->json( [
"message" => "ok",
"title" => $insert['stat_title']
], 201 );
}
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts&do=saveBlock" )->setQueryString(
[ 'block' => Request::i()->block, 'subblock' => Request::i()->subblock, 'blockapp' => Request::i()->blockapp ]
) );
}
Output::i()->output = (string) $form;
}
/**
* Delete an overview stats block
*
* @return void
* @throws \IPS\Api\Exception
*/
protected function deleteBlock()
{
Session::i()->csrfCheck();
if ( !isset( Request::i()->saved_block_id ) or !is_numeric( Request::i()->saved_block_id ) )
{
Output::i()->error( "Cannot delete because there is no saved_block_id in the request", 400 );
}
$id = (int) Request::i()->saved_block_id;
Db::i()->delete( "core_saved_overview_stats", [ "id=?", $id] );
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts&tab=blocks" ) );
}
/**
* Download a CSV of this report
* @return void
*/
protected function download()
{
$csvContents = $this->activeTab === 'charts' ? $this->_compileCharts() : $this->_compileBlocks();
$lang = Member::loggedIn()->language();
$name = $lang->get( 'menu__core_overview_mycharts' ) . ' - ' . $lang->get( $this->activeTab === 'charts' ? 'statsreports_chart_fname' : 'statsreports_block_fname' );
// todo use the report's title when that's implemented
/* Compile the data */
$file = tempnam( TEMP_DIRECTORY, 'IPS' );
$fh = fopen( $file, 'w' );
fputcsv( $fh, $csvContents['headers'] );
foreach( $csvContents['data'] as $row )
{
fputcsv( $fh, $row );
}
fclose( $fh );
$csv = \file_get_contents( $file );
Member::loggedIn()->language()->parseOutputForDisplay( $csv );
// Output::i()->sendOutput( $csv, 200, 'text/plain' ); // for debugging
Output::i()->sendOutput( $csv, 200, 'text/csv', array( 'Content-Disposition' => Output::getContentDisposition( 'attachment', "{$name}.csv" ) ), FALSE, FALSE, FALSE );
}
/**
* Get all the charts in this report merged into one array
*
* @return array{headers: string[], data: string[][]}
*/
protected function _compileCharts() : array
{
$dateRange = $this->_getDateRange();
$data = [
"headers" => [ Member::loggedIn()->language()->addToStack( 'date')],
"data" => []
];
Member::loggedIn()->language()->parseOutputForDisplay( $data['headers'][0] );
$rowsByDate = [];
$timescale = null;
$totalColumnCount = 0;
// the type needs to be set before the charts are initialized
if ( isset( Request::i()->types ) and is_iterable( Request::i()->types ) )
{
foreach( Request::i()->types as $identifier => $type )
{
Request::i()->types[ $identifier][ $type] = "AreaChart";
}
}
$url = Url::internal( "app=core&module=overview&controller=mycharts" );
Dynamic::$mergedChartType = 'AreaChart';
foreach( Chart::getChartsForMember( $url ) AS $chartData )
{
$chart = $chartData['chart'];
if ( !( $chart instanceof Dynamic ) )
{
continue;
}
$timescale = $timescale ?: $chart->timescale;
// Cannot add pie charts
if ( $chart->type === 'PieChart' or $chart->type === 'GeoChart' )
{
continue; // todo this should probably be indicated to the user if the chart cannot be included in the CSV
}
$chart->timescale = $timescale;
$chart->start = $dateRange['start'];
$chart->end = $dateRange['end'];
$chart->compileForOutput();
$rawHeaders = $chart->headers;
$columnCount = 0;
foreach ( $rawHeaders as $header )
{
$formattedHeader = $chart->title . ' - ' . str_replace( "\n", ' ', $header['label'] );
Member::loggedIn()->language()->parseOutputForDisplay( $formattedHeader );
// if the first column is a date, it's the indexable date type
if ( $columnCount === 0 and in_array( $header['type'], [ 'date', 'datetime', 'timeofday' ] ) )
{
continue;
}
$columnCount++;
$data['headers'][] = $formattedHeader;
}
if ( $columnCount )
{
$insertedDates = [];
$rawRows = $chart->rows;
foreach( $rawRows AS $row )
{
$i = 0;
$save = array();
$date = null;
foreach( $rawHeaders AS $header )
{
switch( $header['type'] )
{
/* Booleans convert to string 'true' or 'false' */
case 'bool':
$save[] = ( $row[$i] ) ? 'true' : 'false';
break;
/* Make dates human readable */
case 'date':
case 'datetime':
case 'timeofday':
// In theory, this should be the first column in the chart
if ( $i === 0 )
{
$date = "ts_" . ( new DateTime( $row[$i] ) )->getTimestamp(); // using ts_ before the date to prevent the arrays from auto-filling numeric indices
}
else
{
$save[] = ( new DateTime( $row[ $i ] ) )->fullYearLocaleDate();
}
break;
/* Anything else can just save as-is */
default:
if ( \is_array( $row[$i] ) )
{
/* GeoCharts use arrays so we need to use the value rather */
$save[] = $row[$i]['value'];
}
else
{
$save[] = $row[$i];
}
break;
}
$i++;
}
if ( $date )
{
$insertedDates[$date] = $date;
if ( isset( $rowsByDate[$date] ) )
{
$rowsByDate[$date] = array_merge( $rowsByDate[$date], $save );
}
else
{
$rowsByDate[$date] = array_merge( array_fill( 0, $totalColumnCount, "NULL" ), $save );
}
}
}
// after iterating through the rows, we need to make sure we add empty values for dates in the output
foreach( $rowsByDate as $rowDate => $rowValues )
{
if ( array_key_exists( $rowDate, $insertedDates ) )
{
continue;
}
$rowsByDate[$rowDate] = array_merge( $rowValues, array_fill( 0, $columnCount, "NULL" ) );
}
}
$totalColumnCount += $columnCount;
}
ksort( $rowsByDate );
foreach( $rowsByDate as $date => $rowValues )
{
// the dates are ts_<unix timestamp as an int>
$dateString = ( DateTime::ts( (int) \mb_substr( $date, 3 ) ) )->fullYearLocaleDate();
$data['data'][] = array_merge( array( $dateString ), $rowValues );
}
return $data;
}
/**
* Get the date range based on the request for a report download
*
* @return array{start: DateTime, end: DateTime}
*/
protected function _getDateRange() : array
{
if ( isset( Request::i()->range ) and is_numeric( Request::i()->range ) )
{
$days = (int) Request::i()->range;
return [
"start" => DateTime::create()->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) ),
"end" => DateTime::create()->setTime( 23, 59, 59 ),
];
}
if ( is_array( Request::i()->range ) and is_numeric( @Request::i()->range['start'] ) and is_numeric( @Request::i()->range['end'] ) )
{
return [
"start" => DateTime::ts( (int) Request::i()->range['start'] )->setTime( 23, 59, 59 ),
"end" => DateTime::ts( (int) Request::i()->range['end'] )->setTime( 0, 0, 0 )->sub( new DateInterval( "P7D" ) ),
];
}
return [
"start" => DateTime::create()->setTime( 0, 0, 0 )->sub( new DateInterval( "P7D" ) ), // The default time interval is 7 days
"end" => DateTime::create()->setTime( 23, 59, 59 ),
];
}
/**
* Get all the blocks in this report merged into one array
*
* @return array{headers: string[], data: string[][]}
*/
protected function _compileBlocks() : array
{
$returnData = [
"headers" => [],
"data" => []
];
$rowCount = 0;
$columns = [];
$dateRange = $this->_getDateRange();
foreach( Chart::getSavedBlocks() as $block )
{
$title = Member::loggedIn()->language()->addToStack( $block["details"]["title"] );
$numbersFound = [];
if ( is_callable( array( $block['extension'], 'getBlockNumbers' ) ) )
{
// Allow extensions to define their own numbers
$numbersFound = $block['extension']->getBlockNumbers( $dateRange, $block['subblock'] );
if ( !is_array( $numbersFound ) )
{
$numbersFound = [[ Member::loggedIn()->language()->get( "statsreports_legacy_block" ) ]];
}
}
else
{
// Here we use the same logic from the JS to find numbers
$content = $block['extension']->getBlock( $dateRange, $block['subblock'] );
Member::loggedIn()->language()->parseOutputForDisplay( $content );
$doc = new DOMDocument();
$doc->loadHTML( "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head><body>" . $content . "</body></html>" );
$path = new DOMXPath( $doc );
$previousPeriodLangString = Member::loggedIn()->language()->addToStack( "previous_period" );
Member::loggedIn()->language()->parseOutputForDisplay( $previousPeriodLangString );
$numbersFound = [[]];
foreach ( ( $path->query( "//*[@data-number]|//*[@title][contains(@class, 'cStat__change')]" ) ?? [] ) as $node )
{
if ( $node instanceof DOMElement )
{
if ( $node->hasAttribute( "data-number" ) )
{
$numbersFound[0][] = trim( $node->getAttribute( "data-number" ) ) ?: 0;
}
else if ( $node->hasAttribute( "title" ) and ( $titleAttr = $node->getAttribute( "title" ) and str_starts_with( $titleAttr, $previousPeriodLangString ) ) )
{
$numbersFound[1][] = trim( str_replace( $previousPeriodLangString, '', $titleAttr ) ) ?: 0;
}
}
}
if ( count( $numbersFound ) === 2 )
{
$numbersFound = [
"" => $numbersFound[0],
"statsreports_previous_count" => $numbersFound[1]
];
}
}
$numbersFound = is_array( $numbersFound ) ? $numbersFound : [ $numbersFound ];
foreach ( $numbersFound as $idx => $numbersFoundCol )
{
// we need to make sure this is a column and not just a scalar or associative array
$numbersFoundCol = is_array( $numbersFoundCol ) ? $numbersFoundCol : [ $numbersFoundCol ];
$numbersFoundCol = array_values( $numbersFoundCol );
// When the column isn't named, we index by the block name and column number
if ( !is_string( $idx ) )
{
$idx = $title . (count($numbersFound) > 1 ? " - (" . $idx + 1 . ")" : "");
}
else
{
$idx = $title . ($idx ? ' - ' . Member::loggedIn()->language()->addToStack( $idx ) : "");
}
Member::loggedIn()->language()->parseOutputForDisplay( $idx );
$returnData['headers'][] = $idx;
$rowCount = max( $rowCount, count( $numbersFoundCol ) );
if ( $rowCount > count( $numbersFoundCol ) )
{
$numbersFoundCol = array_merge( $numbersFoundCol, array_fill( 0, $rowCount - count( $numbersFoundCol ), "NULL" ) );
}
$columns[] = $numbersFoundCol;
}
}
// now we need to transpose the columns into rows
for( $column = 0; $column < count( $columns ); $column++ )
{
for ( $row = 0; $row < $rowCount; $row++ )
{
$returnData['data'][$row] = $returnData['data'][$row] ?? [];
$returnData['data'][$row][$column] = $columns[$column][$row] ?? "NULL";
}
}
return $returnData;
}
}