diff --git a/Controller/ExportController.php b/Controller/ExportController.php index bfbdedb81..7fff3fe9a 100644 --- a/Controller/ExportController.php +++ b/Controller/ExportController.php @@ -405,9 +405,6 @@ class ExportController extends Controller $formFormatter->handleRequest($request); $dataFormatter = $formFormatter->getData(); - // temporary hack due to bug with fputcsv and header_get - header('Content-Type: text/csv'); - $r = $exportManager->generate($alias, $dataCenters['centers'], $dataExport['export'], $dataFormatter['formatter']); diff --git a/Export/AggregatorInterface.php b/Export/AggregatorInterface.php index ec3fba739..db65600bd 100644 --- a/Export/AggregatorInterface.php +++ b/Export/AggregatorInterface.php @@ -41,11 +41,49 @@ interface AggregatorInterface extends ModifierInterface public function getQueryKeys($data); /** - * transform the results to viewable and understable string. + * get a callable which will be able to transform the results into + * viewable and understable string. + * + * The callable will have only one argument: the `value` to translate. + * + * The callable should also be able to return a key `_header`, which + * will contains the header of the column. + * + * The string returned **must** be already translated if necessary, + * **with an exception** for the string returned for `_header`. + * + * Example : + * + * ``` + * protected $translator; + * + * public function getLabels($key, array $values, $data) + * { + * return function($value) { + * case $value + * { + * case '_header' : + * return 'my header not translated'; + * case true: + * return $this->translator->trans('true'); + * case false: + * return $this->translator->trans('false'); + * default: + * // this should not happens ! + * throw new \LogicException(); + * } + * } + * ``` + * + * **Note:** Why each string must be translated with an exception for + * the `_header` ? For performance reasons: most of the value will be number + * which do not need to be translated, or value already translated in + * database. But the header must be, in every case, translated. * * @param string $key The column key, as added in the query - * @param mixed[] $values The values from the result. Each value is unique - * @param mixed $data The data from the form + * @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR') + * @param mixed $data The data from the export's form (as defined in `buildForm` + * @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }` */ public function getLabels($key, array $values, $data); diff --git a/Export/ExportInterface.php b/Export/ExportInterface.php index 5b68b491f..e1efa4c05 100644 --- a/Export/ExportInterface.php +++ b/Export/ExportInterface.php @@ -116,8 +116,42 @@ interface ExportInterface extends ExportElementInterface /** * transform the results to viewable and understable string. * - * The result should also contains an entry with a key _header which should - * have, as value, a string for the header. See example in return declaration + * The callable will have only one argument: the `value` to translate. + * + * The callable should also be able to return a key `_header`, which + * will contains the header of the column. + * + * The string returned **must** be already translated if necessary, + * **with an exception** for the string returned for `_header`. + * + * Example : + * + * ``` + * protected $translator; + * + * public function getLabels($key, array $values, $data) + * { + * return function($value) { + * case $value + * { + * case '_header' : + * return 'my header not translated'; + * case true: + * return $this->translator->trans('true'); + * case false: + * return $this->translator->trans('false'); + * default: + * // this should not happens ! + * throw new \LogicException(); + * } + * } + * ``` + * + * **Note:** Why each string must be translated with an exception for + * the `_header` ? For performance reasons: most of the value will be number + * which do not need to be translated, or value already translated in + * database. But the header must be, in every case, translated. + * * * @param string $key The column key, as added in the query * @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR') diff --git a/Export/Formatter/CSVFormatter.php b/Export/Formatter/CSVFormatter.php index c0ea2df35..1beebdef4 100644 --- a/Export/Formatter/CSVFormatter.php +++ b/Export/Formatter/CSVFormatter.php @@ -33,6 +33,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType; * * * @author Julien Fastré + * @deprecated this formatter is not used any more. */ class CSVFormatter implements FormatterInterface { diff --git a/Export/Formatter/SpreadSheetFormatter.php b/Export/Formatter/SpreadSheetFormatter.php new file mode 100644 index 000000000..b043b9564 --- /dev/null +++ b/Export/Formatter/SpreadSheetFormatter.php @@ -0,0 +1,581 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Export\Formatter; + +use Symfony\Component\HttpFoundation\Response; +use Chill\MainBundle\Export\FormatterInterface; +use Symfony\Component\Translation\TranslatorInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Chill\MainBundle\Export\ExportManager; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use PhpOffice\PhpSpreadsheet\Worksheet; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +/** + * + * + * @author Julien Fastré + */ +class SpreadSheetFormatter implements FormatterInterface +{ + /** + * + * @var TranslatorInterface + */ + protected $translator; + + /** + * + * @var ExportManager + */ + protected $exportManager; + + /** + * The result, as returned by the export + * + * replaced when `getResponse` is called. + * + * @var type + */ + protected $result; + + /** + * + * replaced when `getResponse` is called. + * + * @var type + */ + protected $formatterData; + + /** + * The export + * + * replaced when `getResponse` is called. + * + * @var \Chill\MainBundle\Export\ExportInterface + */ + protected $export; + + /** + * + * replaced when `getResponse` is called. + * + * @var type + */ + //protected $aggregators; + + /** + * array containing value of export form + * + * replaced when `getResponse` is called. + * + * @var array + */ + protected $exportData; + + /** + * an array where keys are the aggregators aliases and + * values are the data + * + * replaced when `getResponse` is called. + * + * @var type + */ + protected $aggregatorsData; + + /** + * + * replaced when `getResponse` is called. + * + * @var array + */ + protected $filtersData; + + /** + * + * replaced when `getResponse` is called. + * + * @var array + */ + //protected $labels; + + /** + * temporary file to store spreadsheet + * + * @var string + */ + protected $tempfile; + + /** + * cache for displayable result. + * + * This cache is reset when `getResponse` is called. + * + * The array's keys are the keys in the raw result, and + * values are the callable which will transform the raw result to + * displayable result. + * + * @var array + */ + private $cacheDisplayableResult; + + /** + * Whethe `cacheDisplayableResult` is initialized or not. + * + * @var boolean + */ + private $cacheDisplayableResultIsInitialized = false; + + public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager) + { + $this->translator = $translatorInterface; + $this->exportManager = $exportManager; + } + + + public function buildForm( + FormBuilderInterface $builder, + $exportAlias, + array $aggregatorAliases + ) { + // choosing between formats + $builder->add('format', ChoiceType::class, array( + 'choices' => array( + 'OpenDocument Format (.ods) (LibreOffice, ...)' => 'ods', + 'Microsoft Excel 2007-2013 XML (.xlsx) (Microsoft Excel, LibreOffice)' => 'xlsx', + 'Comma separated values (.csv)' => 'csv' + ), + 'choices_as_values' => true, + 'placeholder' => 'Choose the format' + )); + + // ordering aggregators + $aggregators = $this->exportManager->getAggregators($aggregatorAliases); + $nb = count($aggregatorAliases); + + foreach ($aggregators as $alias => $aggregator) { + $builderAggregator = $builder->create($alias, FormType::class, array( + 'label' => $aggregator->getTitle(), + 'block_name' => '_aggregator_placement_spreadsheet_formatter' + )); + $this->appendAggregatorForm($builderAggregator, $nb); + $builder->add($builderAggregator); + } + } + + /** + * append a form line by aggregator on the formatter form. + * + * This form allow to choose the aggregator position (row or column) and + * the ordering + * + * @param FormBuilderInterface $builder + * @param string $nbAggregators + */ + private function appendAggregatorForm(FormBuilderInterface $builder, $nbAggregators) + { + $builder->add('order', 'choice', array( + 'choices' => array_combine( + range(1, $nbAggregators), + range(1, $nbAggregators) + ), + 'multiple' => false, + 'expanded' => false + )); + + } + + public function getName() + { + return 'SpreadSheet (xlsx, ods)'; + } + + public function getResponse( + $result, + $formatterData, + $exportAlias, + array $exportData, + array $filtersData, + array $aggregatorsData + ) { + // store all data when the process is initiated + $this->result = $result; + $this->formatterData = $formatterData; + $this->export = $this->exportManager->getExport($exportAlias); + $this->exportData = $exportData; + $this->filtersData = $filtersData; + $this->aggregatorsData = $aggregatorsData; + + // reset cache + $this->cacheDisplayableResult = array(); + $this->cacheDisplayableResultIsInitialized = false; + + $response = new Response(); + $response->headers->set('Content-Type', + $this->getContentType($this->formatterData['format'])); + + $this->tempfile = \tempnam(\sys_get_temp_dir(), ''); + $this->generatecontent(); + + $f = \fopen($this->tempfile, 'r'); + $response->setContent(\stream_get_contents($f)); + fclose($f); + + // remove the temp file from disk + \unlink($this->tempfile); + + return $response; + } + + /** + * Generate the content and write it to php://temp + */ + protected function generateContent() + { + list($spreadsheet, $worksheet) = $this->createSpreadsheet(); + + $this->addTitleToWorkSheet($worksheet); + $line = $this->addFiltersDescription($worksheet); + + // at this point, we are going to sort retsults for an easier manipulation + list($sortedResult, $exportKeys, $aggregatorKeys, $globalKeys) = + $this->sortResult(); + + $line = $this->addHeaders($worksheet, $globalKeys, $line); + + $line = $this->addContentTable($worksheet, $sortedResult, $line); + + switch ($this->formatterData['format']) + { + case 'ods': + $writer = \PhpOffice\PhpSpreadsheet\IOFactory + ::createWriter($spreadsheet, 'Ods'); + break; + case 'xlsx': + $writer = \PhpOffice\PhpSpreadsheet\IOFactory + ::createWriter($spreadsheet, 'Xlsx'); + break; + case 'csv': + $writer = \PhpOffice\PhpSpreadsheet\IOFactory + ::createWriter($spreadsheet, 'Csv'); + break; + default: + // this should not happen + // throw an exception to ensure that the error is catched + throw new \LogicException(); + } + + $writer->save($this->tempfile); + } + + /** + * Create a spreadsheet and a working worksheet + * + * @return array where 1st member is spreadsheet, 2nd is worksheet + */ + protected function createSpreadsheet() + { + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + + // setting the worksheet title and code name + $worksheet + ->setTitle($this->getTitle()) + ->setCodeName('result'); + + return [$spreadsheet, $worksheet]; + } + + /** + * Add the title to the worksheet and merge the cell containing + * the title + * + * @param Worksheet $worksheet + */ + protected function addTitleToWorkSheet(Worksheet &$worksheet) + { + $worksheet->setCellValue('A1', $this->getTitle()); + $worksheet->mergeCells('A1:G1'); + } + + /** + * Add filter description since line 3. + * + * return the line number after the last description + * + * @param Worksheet $worksheet + * @return int the line number after the last description + */ + protected function addFiltersDescription(Worksheet &$worksheet) + { + $line = 3; + + foreach ($this->filtersData as $alias => $data) { + $filter = $this->exportManager->getFilter($alias); + $description = $filter->describeAction($data, 'string'); + + if (is_array($description)) { + $description = $this->translator + ->trans($description[0], $description[1]); + } + + $worksheet->setCellValue('A'.$line, $description); + $line ++; + } + + return $line; + } + + /** + * sort the results, and return an array where : + * - 0 => sorted results + * - 1 => export keys + * - 2 => aggregator keys + * - 3 => global keys (aggregator keys and export keys) + * + * + * Example, assuming that the result contains two aggregator keys : + * + * array in result : + * + * ``` + * array( + * array( //row 1 + * 'export_result' => 1, + * 'aggregator_1' => 2, + * 'aggregator_2' => 3 + * ), + * array( // row 2 + * 'export_result' => 4, + * 'aggregator_1' => 5, + * 'aggregator_2' => 6 + * ) + * ) + * ``` + * + * the sorted result will be : + * + * ``` + * array( + * array( 2, 3, 1 ), + * array( 5, 6, 4 ) + * ) + * ``` + * + */ + protected function sortResult() + { + // get the keys for each row + $exportKeys = $this->export->getQueryKeys($this->exportData); + $aggregatorKeys = $this->getAggregatorKeysSorted(); + + $globalKeys = \array_merge($aggregatorKeys, $exportKeys); + + $sortedResult = \array_map(function ($row) use ($globalKeys) { + $newRow = array(); + + foreach ($globalKeys as $key) { + $newRow[] = $this->getDisplayableResult($key, $row[$key]); + } + + return $newRow; + }, $this->result); + + \array_multisort($sortedResult); + + return array($sortedResult, $exportKeys, $aggregatorKeys, $globalKeys); + } + + /** + * get an array of aggregator keys. The keys are sorted as asked + * by user in the form. + * + * @return string[] an array containing the keys of aggregators + */ + protected function getAggregatorKeysSorted() + { + // empty array for aggregators keys + $keys = array(); + // this association between key and aggregator alias will be used + // during sorting + $aggregatorKeyAssociation = array(); + + foreach ($this->aggregatorsData as $alias => $data) { + $aggregator = $this->exportManager->getAggregator($alias); + $aggregatorsKeys = $aggregator->getQueryKeys($data); + // append the keys from aggregator to the $keys existing array + $keys = \array_merge($keys, $aggregatorsKeys); + // append the key with the alias, which will be use later for sorting + foreach ($aggregatorsKeys as $key) { + $aggregatorKeyAssociation[$key] = $alias; + } + } + + // sort the result using the form + usort($keys, function ($a, $b) use ($aggregatorKeyAssociation) { + $A = $this->formatterData[$aggregatorKeyAssociation[$a]]['order']; + $B = $this->formatterData[$aggregatorKeyAssociation[$b]]['order']; + + if ($A === $B) { + return 0; + } elseif ($A > $B) { + return 1; + } else { + return -1; + } + + }); + + return $keys; + } + + /** + * add headers to worksheet + * + * return the line number where the next content (i.e. result) should + * be appended. + * + * @param Worksheet $worksheet + * @param array $aggregatorKeys + * @param array $exportKeys + * @param int $line + * @return int + */ + protected function addHeaders( + Worksheet &$worksheet, + array $globalKeys, + $line + ) { + // get the displayable form of headers + $displayables = array(); + foreach ($globalKeys as $key) { + $displayables[] = $this->translator->trans( + $this->getDisplayableResult($key, '_header') + ); + } + + // add headers on worksheet + $worksheet->fromArray( + $displayables, + NULL, + 'A'.$line); + + return $line + 1; + } + + protected function addContentTable(Worksheet $worksheet, + $sortedResults, + $line + ) { + $worksheet->fromArray( + $sortedResults, + NULL, + 'A'.$line); + + return $line + count($sortedResults) + 1; + } + + protected function getTitle() + { + return $this->translator->trans($this->export->getTitle()); + } + + /** + * Get the displayable result. + * + * @param string $key + * @param string $value + * @return string + */ + protected function getDisplayableResult($key, $value) + { + if ($this->cacheDisplayableResultIsInitialized === false) { + $this->initializeCache($key); + } + + return call_user_func($this->cacheDisplayableResult[$key], $value); + } + + protected function initializeCache($key) + { + /* + * this function follows the following steps : + * + * 1. associate all keys used in result with their export element + * (export or aggregator) and data; + * 2. associate all keys used in result with all the possible values : + * this array will be necessary to call `getLabels` + * 3. store the `callable` in an associative array, in cache + */ + // 1. create an associative array with key and export / aggregator + $keysExportElementAssociation = array(); + // keys for export + foreach ($this->export->getQueryKeys($this->exportData) as $key) { + $keysExportElementAssociation[$key] = [$this->export, + $this->exportData]; + } + // keys for aggregator + foreach ($this->aggregatorsData as $alias => $data) { + $aggregator = $this->exportManager->getAggregator($alias); + + foreach ($aggregator->getQueryKeys($data) as $key) { + $keysExportElementAssociation[$key] = [$aggregator, $data]; + } + } + + // 2. collect all the keys before iteration + $keys = array_keys($keysExportElementAssociation); + + $allValues = array(); + // store all the values in an array + foreach ($this->result as $row) { + foreach ($keys as $key) { + $allValues[$key][] = $row[$key]; + } + } + + // 3. iterate on `keysExportElementAssociation` to store the callable + // in cache + foreach ($keysExportElementAssociation as $key => list($element, $data)) { + $this->cacheDisplayableResult[$key] = + $element->getLabels($key, \array_unique($allValues[$key]), $data); + } + + // the cache is initialized ! + $this->cacheDisplayableResultIsInitialized = true; + } + + protected function getContentType($format) + { + switch ($format) + { + case 'csv': + return 'text/csv'; + case 'ods': + return 'application/vnd.oasis.opendocument.spreadsheet'; + case 'xlsx': + return ' application/vnd.openxmlformats-officedocument.' + . 'spreadsheetml.sheet'; + } + } + + public function getType() + { + return 'tabular'; + } +} diff --git a/Resources/config/services.yml b/Resources/config/services.yml index a5c4b4687..ad81a5738 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -187,28 +187,3 @@ services: - "@chill.main.export_manager" tags: - { name: form.type } - - - chill.main.export.csv_formatter: - class: Chill\MainBundle\Export\Formatter\CSVFormatter - arguments: - - "@translator" - - "@chill.main.export_manager" - tags: - - { name: chill.export_formatter, alias: 'csv' } - - chill.main.export.list_formatter: - class: Chill\MainBundle\Export\Formatter\CSVListFormatter - arguments: - - "@translator" - - "@chill.main.export_manager" - tags: - - { name: chill.export_formatter, alias: 'csvlist' } - - chill.main.export.pivoted_list_formatter: - class: Chill\MainBundle\Export\Formatter\CSVPivotedListFormatter - arguments: - - "@translator" - - "@chill.main.export_manager" - tags: - - { name: chill.export_formatter, alias: 'csv_pivoted_list' } diff --git a/Resources/config/services/export.yml b/Resources/config/services/export.yml index 84925f97a..45ae31181 100644 --- a/Resources/config/services/export.yml +++ b/Resources/config/services/export.yml @@ -3,4 +3,37 @@ services: class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator tags: - { name: validator.constraint_validator } + +# deprecated in favor of spreadsheet_formatter +# chill.main.export.csv_formatter: +# class: Chill\MainBundle\Export\Formatter\CSVFormatter +# arguments: +# - "@translator" +# - "@chill.main.export_manager" +# tags: +# - { name: chill.export_formatter, alias: 'csv' } + + chill.main.export.spreadsheet_formatter: + class: Chill\MainBundle\Export\Formatter\SpreadSheetFormatter + arguments: + - "@translator" + - "@chill.main.export_manager" + tags: + - { name: chill.export_formatter, alias: 'spreadsheet' } + + chill.main.export.list_formatter: + class: Chill\MainBundle\Export\Formatter\CSVListFormatter + arguments: + - "@translator" + - "@chill.main.export_manager" + tags: + - { name: chill.export_formatter, alias: 'csvlist' } + + chill.main.export.pivoted_list_formatter: + class: Chill\MainBundle\Export\Formatter\CSVPivotedListFormatter + arguments: + - "@translator" + - "@chill.main.export_manager" + tags: + - { name: chill.export_formatter, alias: 'csv_pivoted_list' } \ No newline at end of file diff --git a/Resources/translations/messages.fr.yml b/Resources/translations/messages.fr.yml index 8f85fbeb9..a116501a7 100644 --- a/Resources/translations/messages.fr.yml +++ b/Resources/translations/messages.fr.yml @@ -157,3 +157,6 @@ Position: Position row: ligne column: colonne Comma separated values (CSV): Valeurs séparées par des virgules (CSV - tableur) + +# spreadsheet formatter +Choose the format: Choisir le format \ No newline at end of file diff --git a/Resources/views/Form/fields.html.twig b/Resources/views/Form/fields.html.twig index df758ce60..099a42294 100644 --- a/Resources/views/Form/fields.html.twig +++ b/Resources/views/Form/fields.html.twig @@ -144,4 +144,11 @@ {{ form_row(form.position) }} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block _formatter__aggregator_placement_spreadsheet_formatter_row %} +

{{ form_label(form) }}

+ + {{ form_row(form.order) }} + +{% endblock %} diff --git a/Tests/Export/ExportManagerTest.php b/Tests/Export/ExportManagerTest.php index 8faf49def..821baf448 100644 --- a/Tests/Export/ExportManagerTest.php +++ b/Tests/Export/ExportManagerTest.php @@ -579,6 +579,8 @@ class ExportManagerTest extends KernelTestCase case 0: case 1: return $value; + case '_header': + return 'export'; default: throw new \RuntimeException(sprintf("The value %s is not valid", $value)); } }); @@ -612,8 +614,9 @@ class ExportManagerTest extends KernelTestCase ) ->willReturn(function($value) { switch ($value) { - case 'cat a' : return 'label cat a'; - case 'cat b' : return 'label cat b'; + case '_header': return 'foo_header'; + case 'cat a' : return 'label cat a'; + case 'cat b' : return 'label cat b'; default: throw new \RuntimeException(sprintf("This value (%s) is not valid", $value)); } @@ -622,12 +625,12 @@ class ExportManagerTest extends KernelTestCase //$aggregator->addRole()->shouldBeCalled(); $exportManager->addAggregator($aggregator->reveal(), 'aggregator_foo'); - //add csv formatter - $formatter = new \Chill\MainBundle\Export\Formatter\CSVFormatter( + //add formatter interface + $formatter = new \Chill\MainBundle\Export\Formatter\SpreadSheetFormatter( $this->container->get('translator'), $exportManager); - $exportManager->addFormatter($formatter, 'csv'); + $exportManager->addFormatter($formatter, 'spreadsheet'); - ob_start(); + //ob_start(); $response = $exportManager->generate('dummy', array(PickCenterType::CENTERS_IDENTIFIERS => array($center)), array( @@ -644,31 +647,32 @@ class ExportManagerTest extends KernelTestCase ) ), ExportType::PICK_FORMATTER_KEY => array( - 'alias' => 'csv' + 'alias' => 'spreadsheet' ), ExportType::EXPORT_KEY => array( 'a' => 'b' ) ), array( + 'format' => 'csv', 'aggregator_foo' => array( - 'order' => 1, 'position' => 'r' + 'order' => 1 ) ) ); - $content = ob_get_clean(); + //$content = ob_get_clean(); $this->assertInstanceOf(Response::class, $response); $expected = <<assertEquals($expected, $content); + + $this->assertEquals($expected, $response->getContent()); } } diff --git a/composer.json b/composer.json index 84760ab2f..70b7c8a63 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "doctrine/doctrine-bundle": "~1.2", "champs-libres/composer-bundle-migration": "~1.0", "doctrine/doctrine-migrations-bundle": "~1.1", - "doctrine/migrations": "~1.0" + "doctrine/migrations": "~1.0", + "phpoffice/phpspreadsheet": "dev-develop#9e835676a6a2df9f7e445a28d4d89f6bd296a7c5@dev" }, "require-dev": { "symfony/dom-crawler": "2.5",