Skip to content
Draft
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
57 changes: 57 additions & 0 deletions formwork/src/Panel/Controllers/StatisticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

namespace Formwork\Panel\Controllers;

use Formwork\Http\FileResponse;
use Formwork\Http\Response;
use Formwork\Parsers\Csv;
use Formwork\Parsers\Json;
use Formwork\Router\RouteParams;
use Formwork\Statistics\Statistics;
use Formwork\Utils\Arr;
use Formwork\Utils\FileSystem;
use Formwork\Utils\Str;
use ZipArchive;

final class StatisticsController extends AbstractController
{
Expand Down Expand Up @@ -36,4 +43,54 @@ public function index(Statistics $statistics): Response
'weekUniqueVisits' => array_sum($statistics->getUniqueVisits(7)),
]));
}

/**
* Statistics@download action
*/
public function download(Statistics $statistics, RouteParams $routeParams): Response
{
if (!$this->hasPermission('panel.statistics.download')) {
return $this->forward(ErrorsController::class, 'forbidden');
}

$format = $routeParams->get('format', 'tsv');

$chartData = $statistics->getChartData(31, 'Y-m-d');

$stats = [
'devices' => [
['Device', 'Count'],
...Arr::entries($statistics->getDevices()),
],
'pageViews' => [
['Page', 'Count'],
...Arr::entries($statistics->getPageViews()),
],
'sources' => [
['Source', 'Count'],
...Arr::entries($statistics->getSources()),
],
'visits' => [
['Date', 'Visits', 'UniqueVisits'],
...Arr::zip([$chartData['labels'], ...$chartData['series']]),
],
];

$file = FileSystem::joinPaths($this->config->getString('site.statistics.path'), sprintf('.export-%s.zip', FileSystem::randomName()));

$zipArchive = new ZipArchive();
$zipArchive->open($file, ZipArchive::CREATE);

foreach ($stats as $name => $data) {
$zipArchive->addFromString(
sprintf('%s.%s', $name, $format),
Csv::encode($data, ['separator' => $format === 'tsv' ? Csv::SEPARATOR_TAB : Csv::SEPARATOR_COMMA])
);
}

$zipArchive->close();

return (new FileResponse($file, download: true, deleteAfterSend: true))
->setFilename(sprintf('statistics-%s-%s.zip', Str::slug($this->site->title()), date('Ymd-His')));
}
}
119 changes: 119 additions & 0 deletions formwork/src/Parsers/Csv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Formwork\Parsers;

use Formwork\Utils\Arr;
use InvalidArgumentException;
use RuntimeException;

class Csv extends AbstractEncoder
{
public const SEPARATOR_COMMA = ',';

public const SEPARATOR_SEMICOLON = ';';

public const SEPARATOR_TAB = "\t";

public const ENCLOSURE_DOUBLE_QUOTE = '"';

public const ENCLOSURE_SINGLE_QUOTE = "'";

private const array SUPPORTED_SEPARATORS = [
self::SEPARATOR_COMMA,
self::SEPARATOR_SEMICOLON,
self::SEPARATOR_TAB,
];

private const array SUPPORTED_ENCLOSURES = [
self::ENCLOSURE_DOUBLE_QUOTE,
self::ENCLOSURE_SINGLE_QUOTE,
];

/**
* Default options used to parse CSV
*
* @var array{separator: self::SEPARATOR_*, enclosure: self::ENCLOSURE_*}
*/
private const array DEFAULT_OPTIONS = [
'separator' => self::SEPARATOR_COMMA,
'enclosure' => self::ENCLOSURE_DOUBLE_QUOTE,
];

/**
* Parse a CSV string
*
* @param array{separator?: string, enclosure?: string} $options
*
* @return list<list<string|null>>
*/
public static function parse(string $input, array $options = []): array
{
$options = [...self::DEFAULT_OPTIONS, ...$options];

if (!in_array($options['separator'], self::SUPPORTED_SEPARATORS, true)) {
throw new InvalidArgumentException(sprintf('Unsupported CSV separator "%s"', $options['separator']));
}

if (!in_array($options['enclosure'], self::SUPPORTED_ENCLOSURES, true)) {
throw new InvalidArgumentException(sprintf('Unsupported CSV enclosure "%s"', $options['enclosure']));
}

if (($stream = fopen('php://temp', 'w+')) === false) {
throw new RuntimeException('Failed to open temporary stream for CSV parsing');
}

try {
fwrite($stream, $input);
rewind($stream);

$data = [];

while (($row = fgetcsv($stream, null, $options['separator'], $options['enclosure'], '')) !== false) {
if ($row === [null]) {
continue;
}
$data[] = $row;
}

return $data;
} finally {
fclose($stream);
}
}

/**
* Encode data to CSV format
*
* @param array{separator?: string, enclosure?: string} $options
*/
public static function encode(mixed $data, array $options = []): string
{
$options = [...self::DEFAULT_OPTIONS, ...$options];

if (!in_array($options['separator'], self::SUPPORTED_SEPARATORS, true)) {
throw new InvalidArgumentException(sprintf('Unsupported CSV separator "%s"', $options['separator']));
}

if (!in_array($options['enclosure'], self::SUPPORTED_ENCLOSURES, true)) {
throw new InvalidArgumentException(sprintf('Unsupported CSV enclosure "%s"', $options['enclosure']));
}

if (!is_array($data) || Arr::some($data, fn($record) => !is_array($record))) {
throw new RuntimeException('Data must be an array of arrays to encode as CSV');
}

if (($stream = fopen('php://temp', 'w')) === false) {
throw new RuntimeException('Failed to open temporary stream for CSV encoding');
}

try {
foreach ($data as $row) {
fputcsv($stream, $row, $options['separator'], $options['enclosure'], '');
}

return stream_get_contents($stream, offset: 0);
} finally {
fclose($stream);
}
}
}
10 changes: 7 additions & 3 deletions formwork/src/Statistics/Statistics.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ final class Statistics
*/
private const string DATE_FORMAT = 'Ymd';

/**
* Date label format for the statistics chart
*/
private const string LABEL_DATE_FORMAT = "D\nj M";

/**
* Number of days displayed in the statistics chart
*/
Expand Down Expand Up @@ -128,15 +133,14 @@ public function trackVisit(): void
*
* @return array{labels: array<string>, series: list<list<int>>}
*/
public function getChartData(int $limit = self::DEFAULT_CHART_LIMIT): array
public function getChartData(int $limit = self::DEFAULT_CHART_LIMIT, string $labelFormat = self::LABEL_DATE_FORMAT): array
{

$visits = $this->getVisits($limit);
$uniqueVisits = $this->getUniqueVisits($limit);

$labels = Arr::map(
iterator_to_array($this->generateDays($limit)),
fn(string $day): string => Date::formatTimestamp(Date::toTimestamp($day, self::DATE_FORMAT), "D\nj M", $this->translation)
fn(string $day): string => Date::formatTimestamp(Date::toTimestamp($day, self::DATE_FORMAT), $labelFormat, $this->translation)
);

return [
Expand Down
36 changes: 36 additions & 0 deletions formwork/src/Utils/Arr.php
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,42 @@ public static function sort(
return $result;
}

/**
* Combine multiple arrays element by element into a list of entries,
* padding shorter arrays with the default value
*
* @param list<array<mixed>> $arrays
*
* @throws UnexpectedValueException
*
* @return list<list<mixed>>
*/
public static function zip(array $arrays, mixed $default = null): array
{
$entries = 0;

foreach ($arrays as $i => $array) {
// @phpstan-ignore function.alreadyNarrowedType
if (!is_array($array)) {
throw new UnexpectedValueException('All elements of the $arrays parameter must be arrays');
}
$arrays[$i] = array_values($array);
$entries = max($entries, count($array));
}

$result = [];

for ($i = 0; $i < $entries; $i++) {
$entry = [];
foreach ($arrays as $array) {
$entry[] = array_key_exists($i, $array) ? $array[$i] : $default;
}
$result[] = $entry;
}

return $result;
}

/**
* Try to convert the given object to array
*
Expand Down
8 changes: 8 additions & 0 deletions panel/config/routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@
'action' => 'Formwork\Panel\Controllers\StatisticsController@index',
],

'panel.statistics.download' => [
'path' => '/statistics/download/{format}?/',
'action' => 'Formwork\Panel\Controllers\StatisticsController@download',
'where' => [
'format' => ['csv', 'tsv', null],
],
],

'panel.users' => [
'path' => '/users/',
'action' => 'Formwork\Panel\Controllers\UsersController@index',
Expand Down
21 changes: 18 additions & 3 deletions panel/views/statistics/index.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
<?php $this->layout('@panel.panel') ?>

<div class="header">
<div class="header-icon"><?= $this->icon('chart-line') ?></div>
<div class="header-title"><?= $this->translate('panel.statistics.statistics') ?></div>
<div class="flex mr-auto overflow-hidden">
<div class="min-w-0 flex">
<div class="header-icon"><?= $this->icon('chart-line') ?></div>
<div class="header-title"><?= $this->translate('panel.statistics.statistics') ?></div>
</div>
</div>
<div>
<?php if ($panel->user()->permissions()->has('panel.statistics.download')) : ?>
<div class="dropdown mb-0">
<button type="button" class="button button-accent dropdown-button caret" data-dropdown="dropdown-statistics-download"><?= $this->icon('cloud-download') ?> Download</button>
<div class="dropdown-menu" id="dropdown-statistics-download">
<a class="dropdown-item" href="<?= $panel->uri('/statistics/download/tsv/') ?>">Download TSV</a>
<a class="dropdown-item" href="<?= $panel->uri('/statistics/download/csv/') ?>">Download CSV</a>
Comment on lines +13 to +16
</div>
</div>
<?php endif ?>
</div>
</div>

<section class="section">
Expand Down Expand Up @@ -117,4 +132,4 @@
</table>
</section>
</div>
</div>
</div>
Loading