96 lines
3.0 KiB
PHP
96 lines
3.0 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Service\Export;
|
||
|
|
|
||
|
|
use InvalidArgumentException;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Writes CSV rows to a stream.
|
||
|
|
*
|
||
|
|
* Two flavors:
|
||
|
|
* - csv: comma delimited, UTF-8, no BOM (RFC-4180-ish)
|
||
|
|
* - excel: semicolon delimited, UTF-8 with BOM (opens natively in Excel on Windows)
|
||
|
|
*
|
||
|
|
* Values are sanitized against formula injection (=, @, +, -).
|
||
|
|
* HTTP headers and output buffering are handled by the caller
|
||
|
|
* (see core/Support/helpers/export.php).
|
||
|
|
*/
|
||
|
|
final class CsvExportService
|
||
|
|
{
|
||
|
|
public const FLAVOR_CSV = 'csv';
|
||
|
|
public const FLAVOR_EXCEL = 'excel';
|
||
|
|
|
||
|
|
private const UTF8_BOM = "\xEF\xBB\xBF";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param resource $handle
|
||
|
|
* @param iterable<array<string,mixed>> $rows
|
||
|
|
* @param list<ExportColumn> $columns
|
||
|
|
*/
|
||
|
|
public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void
|
||
|
|
{
|
||
|
|
if (!is_resource($handle)) {
|
||
|
|
throw new InvalidArgumentException('writeTo() expects an open stream resource');
|
||
|
|
}
|
||
|
|
if ($columns === []) {
|
||
|
|
throw new InvalidArgumentException('At least one column is required');
|
||
|
|
}
|
||
|
|
|
||
|
|
$delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ',';
|
||
|
|
|
||
|
|
if ($flavor === self::FLAVOR_EXCEL) {
|
||
|
|
fwrite($handle, self::UTF8_BOM);
|
||
|
|
}
|
||
|
|
|
||
|
|
$headers = array_map(
|
||
|
|
static fn (ExportColumn $c): string => self::escapeValue($c->header),
|
||
|
|
$columns
|
||
|
|
);
|
||
|
|
fputcsv($handle, $headers, $delimiter, '"', '\\');
|
||
|
|
|
||
|
|
foreach ($rows as $row) {
|
||
|
|
$line = [];
|
||
|
|
foreach ($columns as $column) {
|
||
|
|
$value = ($column->extract)($row);
|
||
|
|
$line[] = self::escapeValue($value, $column->allowSignedNumeric);
|
||
|
|
}
|
||
|
|
fputcsv($handle, $line, $delimiter, '"', '\\');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prefixes a single apostrophe to values that would be interpreted
|
||
|
|
* as a formula by spreadsheet apps. Defuses the leading-character
|
||
|
|
* set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR.
|
||
|
|
*
|
||
|
|
* When $allowSignedNumeric is true, purely numeric/phone-shaped values
|
||
|
|
* starting with + or - are left intact (useful for `+49 …` columns).
|
||
|
|
*
|
||
|
|
* @api Called from page actions that build rows manually
|
||
|
|
*/
|
||
|
|
public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string
|
||
|
|
{
|
||
|
|
if ($value === null) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
if (is_bool($value)) {
|
||
|
|
return $value ? '1' : '0';
|
||
|
|
}
|
||
|
|
$string = (string) $value;
|
||
|
|
if ($string === '') {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$first = $string[0];
|
||
|
|
if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") {
|
||
|
|
return "'" . $string;
|
||
|
|
}
|
||
|
|
if ($first === '+' || $first === '-') {
|
||
|
|
if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) {
|
||
|
|
return $string;
|
||
|
|
}
|
||
|
|
return "'" . $string;
|
||
|
|
}
|
||
|
|
return $string;
|
||
|
|
}
|
||
|
|
}
|