diff --git a/formwork/src/Panel/Controllers/StatisticsController.php b/formwork/src/Panel/Controllers/StatisticsController.php index d1f502ee7..1d0bf0050 100644 --- a/formwork/src/Panel/Controllers/StatisticsController.php +++ b/formwork/src/Panel/Controllers/StatisticsController.php @@ -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 { @@ -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'))); + } } diff --git a/formwork/src/Parsers/Csv.php b/formwork/src/Parsers/Csv.php new file mode 100644 index 000000000..dcc551af6 --- /dev/null +++ b/formwork/src/Parsers/Csv.php @@ -0,0 +1,119 @@ + self::SEPARATOR_COMMA, + 'enclosure' => self::ENCLOSURE_DOUBLE_QUOTE, + ]; + + /** + * Parse a CSV string + * + * @param array{separator?: string, enclosure?: string} $options + * + * @return list> + */ + 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); + } + } +} diff --git a/formwork/src/Statistics/Statistics.php b/formwork/src/Statistics/Statistics.php index 5d74d9247..ff1c3bd3c 100644 --- a/formwork/src/Statistics/Statistics.php +++ b/formwork/src/Statistics/Statistics.php @@ -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 */ @@ -128,15 +133,14 @@ public function trackVisit(): void * * @return array{labels: array, series: list>} */ - 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 [ diff --git a/formwork/src/Utils/Arr.php b/formwork/src/Utils/Arr.php index 65ec09345..ab2f1cec7 100644 --- a/formwork/src/Utils/Arr.php +++ b/formwork/src/Utils/Arr.php @@ -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> $arrays + * + * @throws UnexpectedValueException + * + * @return list> + */ + 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 * diff --git a/panel/config/routes/routes.php b/panel/config/routes/routes.php index 782f9ee17..2e1cb2fb5 100644 --- a/panel/config/routes/routes.php +++ b/panel/config/routes/routes.php @@ -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', diff --git a/panel/views/statistics/index.php b/panel/views/statistics/index.php index 48cc0f6ea..956a79f86 100644 --- a/panel/views/statistics/index.php +++ b/panel/views/statistics/index.php @@ -1,8 +1,23 @@ layout('@panel.panel') ?>
-
icon('chart-line') ?>
-
translate('panel.statistics.statistics') ?>
+
+
+
icon('chart-line') ?>
+
translate('panel.statistics.statistics') ?>
+
+
+
+ user()->permissions()->has('panel.statistics.download')) : ?> + + +
@@ -117,4 +132,4 @@
- \ No newline at end of file +