Fix CSV export to include UTF-8 BOM.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-05-19 22:53:53 -05:00
parent 910aa3b501
commit 41ac7bd810
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
7 changed files with 76 additions and 106 deletions

View File

@ -8,9 +8,8 @@ use App\Entity\Repository\CustomFieldRepository;
use App\Entity\Repository\StationPlaylistRepository; use App\Entity\Repository\StationPlaylistRepository;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\Utilities\File; use App\Service\CsvWriterTempFile;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Csv\Writer;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
class BulkDownloadAction class BulkDownloadAction
@ -46,9 +45,9 @@ class BulkDownloadAction
)->setParameter('storageLocation', $station->getMediaStorageLocation()); )->setParameter('storageLocation', $station->getMediaStorageLocation());
$filename = $station->getShortName() . '_all_media.csv'; $filename = $station->getShortName() . '_all_media.csv';
$tempFile = File::generateTempPath($filename);
$csv = Writer::createFromPath($tempFile, 'w+'); $tempFile = new CsvWriterTempFile();
$csv->setOutputBOM($csv::BOM_UTF8); $csv = $tempFile->getWriter();
/* /*
* NOTE: These field names should correspond with DB property names when converted into short_names. * NOTE: These field names should correspond with DB property names when converted into short_names.
@ -116,10 +115,6 @@ class BulkDownloadAction
$csv->insertOne($bodyRow); $csv->insertOne($bodyRow);
} }
try { return $response->withFileDownload($tempFile->getTempPath(), $filename, 'text/csv');
return $response->withFileDownload($tempFile, $filename, 'text/csv');
} finally {
@unlink($filename);
}
} }
} }

View File

@ -10,12 +10,11 @@ use App\Environment;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\OpenApi; use App\OpenApi;
use App\Utilities\File; use App\Service\CsvWriterTempFile;
use Azura\DoctrineBatchUtils\ReadOnlyBatchIteratorAggregate; use Azura\DoctrineBatchUtils\ReadOnlyBatchIteratorAggregate;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use League\Csv\Writer;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -150,22 +149,19 @@ class HistoryController
Query $query, Query $query,
string $filename string $filename
): ResponseInterface { ): ResponseInterface {
$tempFile = File::generateTempPath($filename); $tempFile = new CsvWriterTempFile();
$csv = $tempFile->getWriter();
$csv = Writer::createFromPath($tempFile, 'w+'); $csv->insertOne([
'Date',
$csv->insertOne( 'Time',
[ 'Listeners',
'Date', 'Delta',
'Time', 'Track',
'Listeners', 'Artist',
'Delta', 'Playlist',
'Track', 'Streamer',
'Artist', ]);
'Playlist',
'Streamer',
]
);
/** @var Entity\SongHistory $sh */ /** @var Entity\SongHistory $sh */
foreach (ReadOnlyBatchIteratorAggregate::fromQuery($query, 100) as $sh) { foreach (ReadOnlyBatchIteratorAggregate::fromQuery($query, 100) as $sh) {
@ -196,6 +192,6 @@ class HistoryController
]); ]);
} }
return $response->withFileDownload($tempFile, $filename, 'text/csv'); return $response->withFileDownload($tempFile->getTempPath(), $filename, 'text/csv');
} }
} }

View File

@ -9,13 +9,12 @@ use App\Environment;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\OpenApi; use App\OpenApi;
use App\Service\CsvWriterTempFile;
use App\Service\DeviceDetector; use App\Service\DeviceDetector;
use App\Service\IpGeolocation; use App\Service\IpGeolocation;
use App\Utilities\File;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Csv\Writer;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -207,9 +206,8 @@ class ListenersAction
array $listeners, array $listeners,
string $filename string $filename
): ResponseInterface { ): ResponseInterface {
$tempFile = File::generateTempPath($filename); $tempFile = new CsvWriterTempFile();
$csv = $tempFile->getWriter();
$csv = Writer::createFromPath($tempFile, 'w+');
$tz = $station->getTimezoneObject(); $tz = $station->getTimezoneObject();
@ -266,6 +264,6 @@ class ListenersAction
$csv->insertOne($exportRow); $csv->insertOne($exportRow);
} }
return $response->withFileDownload($tempFile, $filename, 'text/csv'); return $response->withFileDownload($tempFile->getTempPath(), $filename, 'text/csv');
} }
} }

View File

@ -7,9 +7,8 @@ namespace App\Controller\Api\Stations\Reports;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\Paginator; use App\Paginator;
use App\Service\CsvWriterTempFile;
use App\Sync\Task\RunAutomatedAssignmentTask; use App\Sync\Task\RunAutomatedAssignmentTask;
use App\Utilities\File;
use League\Csv\Writer;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
class PerformanceAction class PerformanceAction
@ -59,9 +58,8 @@ class PerformanceAction
array $reportData, array $reportData,
string $filename string $filename
): ResponseInterface { ): ResponseInterface {
$tempFile = File::generateTempPath($filename); $tempFile = new CsvWriterTempFile();
$csv = $tempFile->getWriter();
$csv = Writer::createFromPath($tempFile, 'w+');
$csv->insertOne( $csv->insertOne(
[ [
@ -95,6 +93,6 @@ class PerformanceAction
]); ]);
} }
return $response->withFileDownload($tempFile, $filename, 'text/csv'); return $response->withFileDownload($tempFile->getTempPath(), $filename, 'text/csv');
} }
} }

View File

@ -9,7 +9,6 @@ use Azura\Files\ExtendedFilesystemInterface;
use InvalidArgumentException; use InvalidArgumentException;
use League\Flysystem\FileAttributes; use League\Flysystem\FileAttributes;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
final class Response extends \Slim\Http\Response final class Response extends \Slim\Http\Response
{ {
@ -80,36 +79,6 @@ final class Response extends \Slim\Http\Response
return parent::withJson($data, $status, $options, $depth); return parent::withJson($data, $status, $options, $depth);
} }
/**
* Stream the contents of a file directly through to the response.
*
* @param string $file_path
* @param null $file_name
*
* @return static
*/
public function renderFile(string $file_path, $file_name = null): Response
{
set_time_limit(600);
if (null === $file_name) {
$file_name = basename($file_path);
}
$stream = $this->streamFactory->createStreamFromFile($file_path);
$response = $this->response
->withHeader('Pragma', 'public')
->withHeader('Expires', '0')
->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
->withHeader('Content-Type', mime_content_type($file_path) ?: '')
->withHeader('Content-Length', (string)filesize($file_path))
->withHeader('Content-Disposition', 'attachment; filename=' . $file_name)
->withBody($stream);
return new Response($response, $this->streamFactory);
}
/** /**
* Write a string of file data to the response as if it is a file for download. * Write a string of file data to the response as if it is a file for download.
* *
@ -136,37 +105,6 @@ final class Response extends \Slim\Http\Response
return new Response($response, $this->streamFactory); return new Response($response, $this->streamFactory);
} }
/**
* Write a stream to the response as if it is a file for download.
*
* @param StreamInterface $fileStream
* @param string $contentType
* @param string|null $fileName
*
* @return static
*/
public function renderStreamAsFile(
StreamInterface $fileStream,
string $contentType,
?string $fileName = null
): Response {
set_time_limit(600);
$response = $this->response
->withHeader('Pragma', 'public')
->withHeader('Expires', '0')
->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
->withHeader('Content-Type', $contentType);
if ($fileName !== null) {
$response = $response->withHeader('Content-Disposition', 'attachment; filename=' . $fileName);
}
$response = $response->withBody($fileStream);
return new Response($response, $this->streamFactory);
}
public function streamFilesystemFile( public function streamFilesystemFile(
ExtendedFilesystemInterface $filesystem, ExtendedFilesystemInterface $filesystem,
string $path, string $path,

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Utilities\File;
use League\Csv\AbstractCsv;
use League\Csv\Writer;
class CsvWriterTempFile
{
protected string $tempPath;
protected Writer $writer;
public function __construct()
{
$this->tempPath = File::generateTempPath('temp_file.csv');
// Append UTF-8 BOM to temp file.
file_put_contents($this->tempPath, AbstractCsv::BOM_UTF8);
$this->writer = Writer::createFromPath($this->tempPath, 'a+');
}
public function getWriter(): Writer
{
return $this->writer;
}
public function getTempPath(): string
{
return $this->tempPath;
}
public function __destruct()
{
@unlink($this->tempPath);
}
}

View File

@ -3,6 +3,7 @@
namespace Functional; namespace Functional;
use Codeception\Util\Shared\Asserts; use Codeception\Util\Shared\Asserts;
use League\Csv\Reader;
class Api_Stations_ReportsCest extends CestAbstract class Api_Stations_ReportsCest extends CestAbstract
{ {
@ -153,18 +154,21 @@ class Api_Stations_ReportsCest extends CestAbstract
$response = $I->grabResponse(); $response = $I->grabResponse();
$responseCsv = str_getcsv($response); $csvReader = Reader::createFromString($response);
$csvReader->setHeaderOffset(0);
$this->assertIsArray($responseCsv); $csvHeaders = $csvReader->getHeader();
$this->assertIsArray($csvHeaders);
$this->assertTrue( $this->assertTrue(
count($responseCsv) > 0, count($csvHeaders) > 0,
'CSV is not empty' 'CSV is not empty'
); );
foreach ($headerFields as $csvHeaderField) { foreach ($headerFields as $csvHeaderField) {
$this->assertContains( $this->assertContains(
$csvHeaderField, $csvHeaderField,
$responseCsv, $csvHeaders,
'CSV has header field' 'CSV has header field'
); );
} }