mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-09 20:49:30 +00:00
Implement excel based import/export
This commit is contained in:
parent
c5751b2aa6
commit
facfb37383
7 changed files with 690 additions and 12 deletions
|
|
@ -59,6 +59,8 @@ class ImportType extends AbstractType
|
|||
'XML' => 'xml',
|
||||
'CSV' => 'csv',
|
||||
'YAML' => 'yaml',
|
||||
'XLSX' => 'xlsx',
|
||||
'XLS' => 'xls',
|
||||
],
|
||||
'label' => 'export.format',
|
||||
'disabled' => $disabled,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use function Symfony\Component\String\u;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xls;
|
||||
|
||||
/**
|
||||
* Use this class to export an entity to multiple file formats.
|
||||
|
|
@ -52,7 +55,7 @@ class EntityExporter
|
|||
protected function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefault('format', 'csv');
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
|
||||
|
||||
$resolver->setDefault('csv_delimiter', ';');
|
||||
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
||||
|
|
@ -88,6 +91,11 @@ class EntityExporter
|
|||
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
//Handle Excel formats by converting from CSV
|
||||
if (in_array($options['format'], ['xlsx', 'xls'])) {
|
||||
return $this->exportToExcel($entities, $options);
|
||||
}
|
||||
|
||||
//If include children is set, then we need to add the include_children group
|
||||
$groups = [$options['level']];
|
||||
if ($options['include_children']) {
|
||||
|
|
@ -122,6 +130,73 @@ class EntityExporter
|
|||
throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports entities to Excel format (xlsx or xls).
|
||||
*
|
||||
* @param AbstractNamedDBElement[] $entities The entities to export
|
||||
* @param array $options The export options
|
||||
*
|
||||
* @return string The Excel file content as binary string
|
||||
*/
|
||||
protected function exportToExcel(array $entities, array $options): string
|
||||
{
|
||||
//First get CSV data using existing serializer
|
||||
$csvOptions = $options;
|
||||
$csvOptions['format'] = 'csv';
|
||||
$groups = [$options['level']];
|
||||
if ($options['include_children']) {
|
||||
$groups[] = 'include_children';
|
||||
}
|
||||
|
||||
$csvData = $this->serializer->serialize($entities, 'csv',
|
||||
[
|
||||
'groups' => $groups,
|
||||
'as_collection' => true,
|
||||
'csv_delimiter' => $options['csv_delimiter'],
|
||||
'partdb_export' => true,
|
||||
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
|
||||
]
|
||||
);
|
||||
|
||||
//Convert CSV to Excel
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$rows = explode("\n", $csvData);
|
||||
$rowIndex = 1;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim($row) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columns = str_getcsv($row, $options['csv_delimiter']);
|
||||
$colIndex = 1;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
|
||||
$worksheet->setCellValue($cellCoordinate, $column);
|
||||
$colIndex++;
|
||||
}
|
||||
$rowIndex++;
|
||||
}
|
||||
|
||||
//Save to memory stream
|
||||
if ($options['format'] === 'xlsx') {
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
} else {
|
||||
$writer = new Xls($spreadsheet);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$writer->save('php://output');
|
||||
$content = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports an Entity or an array of entities to multiple file formats.
|
||||
*
|
||||
|
|
@ -168,6 +243,12 @@ class EntityExporter
|
|||
case 'json':
|
||||
$content_type = 'application/json';
|
||||
break;
|
||||
case 'xlsx':
|
||||
$content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
break;
|
||||
case 'xls':
|
||||
$content_type = 'application/vnd.ms-excel';
|
||||
break;
|
||||
}
|
||||
$response->headers->set('Content-Type', $content_type);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File;
|
|||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
|
||||
|
|
@ -50,7 +53,7 @@ class EntityImporter
|
|||
*/
|
||||
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
|
||||
|
||||
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
|
||||
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +105,7 @@ class EntityImporter
|
|||
|
||||
foreach ($names as $name) {
|
||||
//Count indentation level (whitespace characters at the beginning of the line)
|
||||
$identSize = strlen($name)-strlen(ltrim($name));
|
||||
$identSize = strlen($name) - strlen(ltrim($name));
|
||||
|
||||
//If the line is intended more than the last line, we have a new parent element
|
||||
if ($identSize > end($indentations)) {
|
||||
|
|
@ -195,16 +198,20 @@ class EntityImporter
|
|||
}
|
||||
|
||||
//The [] behind class_name denotes that we expect an array.
|
||||
$entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'],
|
||||
$entities = $this->serializer->deserialize(
|
||||
$data,
|
||||
$options['class'] . '[]',
|
||||
$options['format'],
|
||||
[
|
||||
'groups' => $groups,
|
||||
'csv_delimiter' => $options['csv_delimiter'],
|
||||
'create_unknown_datastructures' => $options['create_unknown_datastructures'],
|
||||
'path_delimiter' => $options['path_delimiter'],
|
||||
'partdb_import' => true,
|
||||
//Disable API Platform normalizer, as we don't want to use it here
|
||||
//Disable API Platform normalizer, as we don't want to use it here
|
||||
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
//Ensure we have an array of entity elements.
|
||||
if (!is_array($entities)) {
|
||||
|
|
@ -279,7 +286,7 @@ class EntityImporter
|
|||
'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
|
||||
]);
|
||||
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
|
||||
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
||||
$resolver->setAllowedTypes('preserve_children', 'bool');
|
||||
$resolver->setAllowedTypes('class', 'string');
|
||||
|
|
@ -335,6 +342,33 @@ class EntityImporter
|
|||
*/
|
||||
public function importFile(File $file, array $options = [], array &$errors = []): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
if (in_array($options['format'], ['xlsx', 'xls'])) {
|
||||
$this->logger->info('Converting Excel file to CSV', [
|
||||
'filename' => $file->getFilename(),
|
||||
'format' => $options['format'],
|
||||
'delimiter' => $options['csv_delimiter']
|
||||
]);
|
||||
|
||||
$csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']);
|
||||
$options['format'] = 'csv';
|
||||
|
||||
$this->logger->debug('Excel to CSV conversion completed', [
|
||||
'csv_length' => strlen($csvData),
|
||||
'csv_lines' => substr_count($csvData, "\n") + 1
|
||||
]);
|
||||
|
||||
// Log the converted CSV for debugging (first 1000 characters)
|
||||
$this->logger->debug('Converted CSV preview', [
|
||||
'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '')
|
||||
]);
|
||||
|
||||
return $this->importString($csvData, $options, $errors);
|
||||
}
|
||||
|
||||
return $this->importString($file->getContent(), $options, $errors);
|
||||
}
|
||||
|
||||
|
|
@ -354,10 +388,103 @@ class EntityImporter
|
|||
'xml' => 'xml',
|
||||
'csv', 'tsv' => 'csv',
|
||||
'yaml', 'yml' => 'yaml',
|
||||
'xlsx' => 'xlsx',
|
||||
'xls' => 'xls',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Excel file to CSV format using PhpSpreadsheet.
|
||||
*
|
||||
* @param File $file The Excel file to convert
|
||||
* @param string $delimiter The CSV delimiter to use
|
||||
*
|
||||
* @return string The CSV data as string
|
||||
*/
|
||||
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
|
||||
{
|
||||
try {
|
||||
$this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]);
|
||||
$spreadsheet = IOFactory::load($file->getPathname());
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$csvData = [];
|
||||
$highestRow = $worksheet->getHighestRow();
|
||||
$highestColumn = $worksheet->getHighestColumn();
|
||||
|
||||
$this->logger->debug('Excel file dimensions', [
|
||||
'rows' => $highestRow,
|
||||
'columns_detected' => $highestColumn,
|
||||
'worksheet_title' => $worksheet->getTitle()
|
||||
]);
|
||||
|
||||
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
|
||||
|
||||
for ($row = 1; $row <= $highestRow; $row++) {
|
||||
$rowData = [];
|
||||
|
||||
// Read all columns using numeric index
|
||||
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
|
||||
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
|
||||
try {
|
||||
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
|
||||
$rowData[] = $cellValue ?? '';
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Error reading cell value', [
|
||||
'cell' => "{$col}{$row}",
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
$rowData[] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) {
|
||||
$value = (string) $value;
|
||||
if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) {
|
||||
return '"' . str_replace('"', '""', $value) . '"';
|
||||
}
|
||||
return $value;
|
||||
}, $rowData));
|
||||
|
||||
$csvData[] = $csvRow;
|
||||
|
||||
// Log first few rows for debugging
|
||||
if ($row <= 3) {
|
||||
$this->logger->debug("Row {$row} converted", [
|
||||
'original_data' => $rowData,
|
||||
'csv_row' => $csvRow,
|
||||
'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(),
|
||||
'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$result = implode("\n", $csvData);
|
||||
|
||||
$this->logger->info('Excel to CSV conversion successful', [
|
||||
'total_rows' => count($csvData),
|
||||
'total_characters' => strlen($result)
|
||||
]);
|
||||
|
||||
$this->logger->debug('Full CSV data', [
|
||||
'csv_data' => $result
|
||||
]);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to convert Excel to CSV', [
|
||||
'file' => $file->getFilename(),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This functions corrects the parent setting based on the children value of the parent.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue