Backend module: CSV import for the VITEC domain models
New "VITEC Import" module under Web: one import page per domain model (v1: Market, Solution, Product - registry-driven, adding a model is one config entry). Workflow: upload a CSV (delimiter and encoding are auto-detected, including German-Excel semicolon/Windows-1252), map CSV columns to DB fields, persist the mapping per model together with the identity field used for matching (new table tx_vitec_import_mapping, no TCA - pure tool configuration), review a unified list of CSV rows matched against the DB records (new / update with differing fields / unchanged / db-only), then apply the checked rows through DataHandler. Each importable row carries an editable JSON payload textarea - what is written is the textarea content, not the raw CSV, so editors can fix values right in the review step. The parsed CSV travels through the form as a hidden JSON field: no session state, no temp files. Importable fields are derived from TCA at runtime (scalar types only; files, categories and other relations are excluded - a flat CSV cannot carry them). Payloads are whitelisted against that field list on apply; new records require a storage pid (prefilled from existing records). BE user permissions apply via DataHandler. Deliberately out of v1: import log with three-way compare (protection against overwriting manual edits), images/relations, multiple saved mappings per model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
343
packages/vitec/Classes/Controller/Backend/ImportController.php
Normal file
343
packages/vitec/Classes/Controller/Backend/ImportController.php
Normal file
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Controller\Backend;
|
||||
|
||||
use Evomedien\Vitec\Import\CsvReader;
|
||||
use Evomedien\Vitec\Import\ImportModelRegistry;
|
||||
use Evomedien\Vitec\Import\MappingRepository;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Backend module: CSV import for the VITEC domain models.
|
||||
*
|
||||
* Workflow per model page (decisions 2026-08-06):
|
||||
* 1. Upload a CSV; delimiter and encoding are auto-detected.
|
||||
* 2. Map CSV columns to DB fields; the mapping (and the identity field
|
||||
* used for matching) is persisted per model via MappingRepository.
|
||||
* 3. A unified list shows CSV rows matched against the DB records:
|
||||
* new / update (with the differing fields) / unchanged / db-only.
|
||||
* Each importable row carries an editable JSON payload textarea -
|
||||
* what is applied is the textarea, not the raw CSV.
|
||||
* 4. Checked rows are written through DataHandler (BE user permissions
|
||||
* apply). New records are created on the configured pid.
|
||||
*
|
||||
* The parsed CSV travels through the form as a hidden JSON field, so the
|
||||
* module needs no server-side session state.
|
||||
*/
|
||||
#[AsController]
|
||||
final class ImportController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||
private readonly ImportModelRegistry $registry,
|
||||
private readonly MappingRepository $mappingRepository,
|
||||
private readonly CsvReader $csvReader,
|
||||
private readonly FlashMessageService $flashMessageService,
|
||||
private readonly UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
public function indexAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$modelKey = (string)($request->getQueryParams()['model'] ?? 'market');
|
||||
if (!$this->registry->has($modelKey)) {
|
||||
$modelKey = 'market';
|
||||
}
|
||||
return $this->render($request, $modelKey, null, null, null);
|
||||
}
|
||||
|
||||
public function processAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$modelKey = (string)($body['model'] ?? '');
|
||||
if (!$this->registry->has($modelKey)) {
|
||||
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport'), 303);
|
||||
}
|
||||
$op = (string)($body['op'] ?? 'preview');
|
||||
|
||||
// CSV: fresh upload wins, otherwise the hidden state field.
|
||||
$csv = null;
|
||||
$files = $request->getUploadedFiles();
|
||||
$upload = $files['csvfile'] ?? null;
|
||||
if ($upload !== null && $upload->getError() === UPLOAD_ERR_OK) {
|
||||
$csv = $this->csvReader->parse((string)$upload->getStream());
|
||||
if ($csv['columns'] === []) {
|
||||
$this->flash('The file could not be parsed as CSV.', 'Upload', false);
|
||||
$csv = null;
|
||||
} else {
|
||||
$this->flash(
|
||||
sprintf('%d rows, %d columns detected.', count($csv['rows']), count($csv['columns'])),
|
||||
'CSV loaded',
|
||||
true
|
||||
);
|
||||
}
|
||||
} elseif (($body['csvstate'] ?? '') !== '') {
|
||||
$decoded = json_decode((string)$body['csvstate'], true);
|
||||
if (is_array($decoded) && isset($decoded['columns'], $decoded['rows'])) {
|
||||
$csv = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping from the form, when present.
|
||||
$mapping = null;
|
||||
$identity = null;
|
||||
if (isset($body['map']) && is_array($body['map'])) {
|
||||
$mapping = [];
|
||||
foreach ($body['map'] as $column => $field) {
|
||||
if ((string)$field !== '') {
|
||||
$mapping[(string)$column] = (string)$field;
|
||||
}
|
||||
}
|
||||
$identity = (string)($body['identity'] ?? '');
|
||||
}
|
||||
|
||||
if ($op === 'saveMapping' && $mapping !== null && $identity !== '') {
|
||||
$this->mappingRepository->save($modelKey, $mapping, $identity);
|
||||
$this->flash('Mapping stored for this model - it will be preselected on the next upload.', 'Mapping saved', true);
|
||||
}
|
||||
|
||||
if ($op === 'apply') {
|
||||
$this->apply($modelKey, $body);
|
||||
}
|
||||
|
||||
return $this->render($request, $modelKey, $csv, $mapping, $identity);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering
|
||||
|
||||
/**
|
||||
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
|
||||
* @param array<string,string>|null $mapping
|
||||
*/
|
||||
private function render(
|
||||
ServerRequestInterface $request,
|
||||
string $modelKey,
|
||||
?array $csv,
|
||||
?array $mapping,
|
||||
?string $identity
|
||||
): ResponseInterface {
|
||||
$model = $this->registry->get($modelKey);
|
||||
$fields = $this->registry->importableFields($model['table']);
|
||||
$saved = $this->mappingRepository->load($modelKey);
|
||||
|
||||
if ($mapping === null) {
|
||||
$mapping = $saved['mapping'] ?? [];
|
||||
if ($mapping === [] && $csv !== null) {
|
||||
$mapping = $this->guessMapping($csv['columns'], $fields);
|
||||
}
|
||||
}
|
||||
if ($identity === null || $identity === '') {
|
||||
$identity = $saved['identity'] ?? (isset($fields['slug']) ? 'slug' : 'title');
|
||||
}
|
||||
|
||||
$union = $this->buildUnion($model['table'], $fields, $csv, $mapping, $identity);
|
||||
|
||||
$counts = ['new' => 0, 'update' => 0, 'unchanged' => 0, 'dbonly' => 0];
|
||||
foreach ($union as $row) {
|
||||
$counts[$row['status']]++;
|
||||
}
|
||||
|
||||
// Column meta for the mapping panel: sample value + current target.
|
||||
$columns = [];
|
||||
foreach ($csv['columns'] ?? [] as $column) {
|
||||
$columns[] = [
|
||||
'name' => $column,
|
||||
'sample' => (string)($csv['rows'][0][$column] ?? ''),
|
||||
'target' => $mapping[$column] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$view->assignMultiple([
|
||||
'models' => $this->registry->all(),
|
||||
'model' => $model,
|
||||
'fields' => $fields,
|
||||
'columns' => $columns,
|
||||
'hasCsv' => $csv !== null,
|
||||
'csvRowCount' => count($csv['rows'] ?? []),
|
||||
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
|
||||
'identity' => $identity,
|
||||
'mappingSaved' => $saved !== null,
|
||||
'union' => $union,
|
||||
'counts' => $counts,
|
||||
'pid' => $this->registry->detectPid($model['table']),
|
||||
]);
|
||||
|
||||
return $view->renderResponse('Import/Index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Name-equality guess for a first-time mapping.
|
||||
*
|
||||
* @param array<int,string> $columns
|
||||
* @param array<string,string> $fields
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function guessMapping(array $columns, array $fields): array
|
||||
{
|
||||
$mapping = [];
|
||||
foreach ($columns as $column) {
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
if (isset($fields[$normalized])) {
|
||||
$mapping[$column] = $normalized;
|
||||
}
|
||||
}
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge CSV rows and DB records into one status-annotated list.
|
||||
*
|
||||
* @param array<string,string> $fields
|
||||
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
|
||||
* @param array<string,string> $mapping
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity): array
|
||||
{
|
||||
$mappedFields = array_values(array_unique(array_values($mapping)));
|
||||
$records = $this->registry->loadRecords($table, array_keys($fields));
|
||||
|
||||
$byIdentity = [];
|
||||
foreach ($records as $uid => $record) {
|
||||
$key = mb_strtolower(trim((string)($record[$identity] ?? '')));
|
||||
if ($key !== '' && !isset($byIdentity[$key])) {
|
||||
$byIdentity[$key] = $uid;
|
||||
}
|
||||
}
|
||||
|
||||
$union = [];
|
||||
$matchedUids = [];
|
||||
$index = 0;
|
||||
|
||||
foreach ($csv['rows'] ?? [] as $raw) {
|
||||
$payload = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$payload[$field] = (string)($raw[$column] ?? '');
|
||||
}
|
||||
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
||||
$uid = $identityValue !== '' ? ($byIdentity[$identityValue] ?? 0) : 0;
|
||||
|
||||
if ($uid > 0) {
|
||||
$matchedUids[$uid] = true;
|
||||
$record = $records[$uid];
|
||||
$diff = [];
|
||||
foreach ($payload as $field => $value) {
|
||||
if (trim($value) !== trim((string)($record[$field] ?? ''))) {
|
||||
$diff[] = $field;
|
||||
}
|
||||
}
|
||||
$status = $diff === [] ? 'unchanged' : 'update';
|
||||
} else {
|
||||
$diff = array_keys($payload);
|
||||
$status = 'new';
|
||||
}
|
||||
|
||||
$union[] = [
|
||||
'index' => $index++,
|
||||
'status' => $status,
|
||||
'uid' => $uid,
|
||||
'label' => (string)($payload['title'] ?? ($payload[$identity] ?? 'row')),
|
||||
'diff' => implode(', ', $diff),
|
||||
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'checked' => $status === 'new' || $status === 'update',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($records as $uid => $record) {
|
||||
if (isset($matchedUids[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$union[] = [
|
||||
'index' => $index++,
|
||||
'status' => 'dbonly',
|
||||
'uid' => $uid,
|
||||
'label' => (string)($record['title'] ?? $uid),
|
||||
'diff' => '',
|
||||
'payload' => '',
|
||||
'checked' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $union;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apply
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
private function apply(string $modelKey, array $body): void
|
||||
{
|
||||
$model = $this->registry->get($modelKey);
|
||||
$fields = $this->registry->importableFields($model['table']);
|
||||
$pid = (int)($body['pid'] ?? 0);
|
||||
$rows = is_array($body['rows'] ?? null) ? $body['rows'] : [];
|
||||
|
||||
$datamap = [];
|
||||
$errors = [];
|
||||
foreach ($rows as $i => $row) {
|
||||
if (empty($row['selected'])) {
|
||||
continue;
|
||||
}
|
||||
$payload = json_decode((string)($row['payload'] ?? ''), true);
|
||||
if (!is_array($payload)) {
|
||||
$errors[] = sprintf('Row %s: payload is not valid JSON - skipped.', $i);
|
||||
continue;
|
||||
}
|
||||
// Whitelist against the TCA-derived field list.
|
||||
$payload = array_intersect_key($payload, $fields);
|
||||
$payload = array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $payload);
|
||||
if ($payload === []) {
|
||||
continue;
|
||||
}
|
||||
$uid = (int)($row['uid'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$datamap[(string)$uid] = $payload;
|
||||
} elseif ($pid > 0) {
|
||||
$datamap['NEW' . $i] = ['pid' => $pid] + $payload;
|
||||
} else {
|
||||
$errors[] = sprintf('Row %s: no storage pid for a new record - skipped.', $i);
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
if ($datamap !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([$model['table'] => $datamap], []);
|
||||
$dataHandler->process_datamap();
|
||||
$errors = array_merge($errors, $dataHandler->errorLog);
|
||||
$created = count($dataHandler->substNEWwithIDs);
|
||||
$updated = count(array_filter(array_keys($datamap), 'is_numeric'));
|
||||
}
|
||||
|
||||
$this->flash(
|
||||
sprintf('%d created, %d updated, %d error(s).', $created, $updated, count($errors))
|
||||
. ($errors !== [] ? ' ' . implode(' | ', array_slice($errors, 0, 5)) : ''),
|
||||
'Import finished',
|
||||
$errors === []
|
||||
);
|
||||
}
|
||||
|
||||
private function flash(string $message, string $title, bool $ok): void
|
||||
{
|
||||
$flashMessage = GeneralUtility::makeInstance(
|
||||
FlashMessage::class,
|
||||
$message,
|
||||
$title,
|
||||
$ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR,
|
||||
true
|
||||
);
|
||||
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flashMessage);
|
||||
}
|
||||
}
|
||||
78
packages/vitec/Classes/Import/CsvReader.php
Normal file
78
packages/vitec/Classes/Import/CsvReader.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
/**
|
||||
* Tolerant CSV parser for editor-supplied files.
|
||||
*
|
||||
* German Excel exports ship with semicolon delimiters and Windows-1252
|
||||
* encoding; other tools use comma/UTF-8. Both are detected instead of
|
||||
* configured: encoding by BOM/UTF-8 validity, delimiter by counting
|
||||
* candidates in the header line. Multiline quoted values (e.g. RTE
|
||||
* descriptions) are handled by fgetcsv on a temp stream.
|
||||
*/
|
||||
final class CsvReader
|
||||
{
|
||||
/**
|
||||
* @return array{columns: array<int,string>, rows: array<int,array<string,string>>}
|
||||
*/
|
||||
public function parse(string $content): array
|
||||
{
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
$content = substr($content, 3);
|
||||
}
|
||||
if (!mb_check_encoding($content, 'UTF-8')) {
|
||||
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
|
||||
}
|
||||
|
||||
$firstLine = strtok($content, "\r\n") ?: '';
|
||||
$delimiter = ';';
|
||||
$best = 0;
|
||||
foreach ([';', ',', "\t"] as $candidate) {
|
||||
$count = substr_count($firstLine, $candidate);
|
||||
if ($count > $best) {
|
||||
$best = $count;
|
||||
$delimiter = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
$header = fgetcsv($stream, null, $delimiter, '"', '');
|
||||
if (!is_array($header)) {
|
||||
fclose($stream);
|
||||
return ['columns' => [], 'rows' => []];
|
||||
}
|
||||
$columns = [];
|
||||
foreach ($header as $i => $name) {
|
||||
$name = trim((string)$name);
|
||||
$columns[$i] = $name !== '' ? $name : ('column_' . ($i + 1));
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while (($data = fgetcsv($stream, null, $delimiter, '"', '')) !== false) {
|
||||
if ($data === [null] || $data === ['']) {
|
||||
continue; // blank line
|
||||
}
|
||||
$row = [];
|
||||
$hasContent = false;
|
||||
foreach ($columns as $i => $name) {
|
||||
$value = trim((string)($data[$i] ?? ''));
|
||||
$row[$name] = $value;
|
||||
if ($value !== '') {
|
||||
$hasContent = true;
|
||||
}
|
||||
}
|
||||
if ($hasContent) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
fclose($stream);
|
||||
|
||||
return ['columns' => array_values($columns), 'rows' => $rows];
|
||||
}
|
||||
}
|
||||
115
packages/vitec/Classes/Import/ImportModelRegistry.php
Normal file
115
packages/vitec/Classes/Import/ImportModelRegistry.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Registry of the domain models the CSV import module can write to.
|
||||
*
|
||||
* Adding a model = one entry in MODELS. The importable fields are derived
|
||||
* from the live TCA at runtime, so new columns show up in the mapper
|
||||
* automatically. Only scalar column types are offered - files, categories
|
||||
* and other relations cannot be carried by a flat CSV and are excluded.
|
||||
*/
|
||||
final class ImportModelRegistry
|
||||
{
|
||||
private const MODELS = [
|
||||
'market' => ['table' => 'tx_vitec_domain_model_market', 'label' => 'Markets'],
|
||||
'solution' => ['table' => 'tx_vitec_domain_model_solution', 'label' => 'Solutions'],
|
||||
'product' => ['table' => 'tx_vitec_domain_model_product', 'label' => 'Products'],
|
||||
];
|
||||
|
||||
/** Scalar TCA column types a CSV cell can populate. */
|
||||
private const IMPORTABLE_TYPES = ['input', 'text', 'slug', 'number', 'email', 'link', 'check', 'color'];
|
||||
|
||||
private const EXCLUDED_FIELDS = [
|
||||
'sys_language_uid', 'l10n_parent', 'l10n_diffsource', 'l10n_source',
|
||||
'l18n_parent', 'l18n_diffsource', 'starttime', 'endtime', 'hidden',
|
||||
];
|
||||
|
||||
/** @return array<string,array{key:string,table:string,label:string}> */
|
||||
public function all(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::MODELS as $key => $cfg) {
|
||||
$out[$key] = $cfg + ['key' => $key];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset(self::MODELS[$key]);
|
||||
}
|
||||
|
||||
/** @return array{key:string,table:string,label:string} */
|
||||
public function get(string $key): array
|
||||
{
|
||||
return self::MODELS[$key] + ['key' => $key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Importable fields of a table, derived from TCA.
|
||||
*
|
||||
* @return array<string,string> fieldName => human label
|
||||
*/
|
||||
public function importableFields(string $table): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($GLOBALS['TCA'][$table]['columns'] ?? [] as $name => $column) {
|
||||
$type = (string)($column['config']['type'] ?? '');
|
||||
if (!in_array($type, self::IMPORTABLE_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, self::EXCLUDED_FIELDS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (($column['config']['renderType'] ?? '') === 'inputDateTime') {
|
||||
continue;
|
||||
}
|
||||
$label = (string)($column['label'] ?? $name);
|
||||
if (str_starts_with($label, 'LLL:')) {
|
||||
$translated = $GLOBALS['LANG'] ?? null ? (string)$GLOBALS['LANG']->sL($label) : '';
|
||||
$label = $translated !== '' ? $translated : $name;
|
||||
}
|
||||
$fields[$name] = $label !== '' ? $label : $name;
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** Pid of the first existing record - the sysfolder the model lives in. */
|
||||
public function detectPid(string $table): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||
$row = $qb->select('pid')->from($table)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchAssociative();
|
||||
return $row ? (int)$row['pid'] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* All live records with the given fields, indexed by uid.
|
||||
*
|
||||
* @param array<int,string> $fields
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function loadRecords(string $table, array $fields): array
|
||||
{
|
||||
$select = array_values(array_unique(array_merge(['uid', 'title'], $fields)));
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||
$rows = $qb->select(...$select)->from($table)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->orderBy('title', 'ASC')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(int)$row['uid']] = $row;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
65
packages/vitec/Classes/Import/MappingRepository.php
Normal file
65
packages/vitec/Classes/Import/MappingRepository.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Persists one CSV-column -> DB-field mapping per import model, so editors
|
||||
* configure the mapper once and reuse it on every delivery.
|
||||
*
|
||||
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
|
||||
* tool configuration, never edited through FormEngine.
|
||||
*/
|
||||
final class MappingRepository
|
||||
{
|
||||
private const TABLE = 'tx_vitec_import_mapping';
|
||||
|
||||
/**
|
||||
* @return array{mapping: array<string,string>, identity: string}|null
|
||||
*/
|
||||
public function load(string $model): ?array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$row = $qb->select('mapping', 'identity_field')->from(self::TABLE)
|
||||
->where($qb->expr()->eq('model', $qb->createNamedParameter($model, ParameterType::STRING)))
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchAssociative();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$mapping = json_decode((string)$row['mapping'], true);
|
||||
if (!is_array($mapping)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'mapping' => array_map('strval', $mapping),
|
||||
'identity' => (string)$row['identity_field'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,string> $mapping */
|
||||
public function save(string $model, array $mapping, string $identity): void
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$values = [
|
||||
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
|
||||
'identity_field' => $identity,
|
||||
'tstamp' => time(),
|
||||
];
|
||||
$exists = $connection->count('uid', self::TABLE, ['model' => $model]) > 0;
|
||||
if ($exists) {
|
||||
$connection->update(self::TABLE, $values, ['model' => $model]);
|
||||
} else {
|
||||
$connection->insert(self::TABLE, $values + [
|
||||
'model' => $model,
|
||||
'pid' => 0,
|
||||
'crdate' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,32 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Evomedien\Vitec\Controller\Backend\ImportController;
|
||||
use Evomedien\Vitec\Controller\OgImageController;
|
||||
|
||||
return [
|
||||
'web_vitecimport' => [
|
||||
'parent' => 'web',
|
||||
'position' => ['after' => 'web_info'],
|
||||
'access' => 'user',
|
||||
'workspaces' => 'live',
|
||||
'path' => '/module/web/vitec-import',
|
||||
'labels' => [
|
||||
'title' => 'VITEC Import',
|
||||
'shortDescription' => 'CSV import for the VITEC domain models',
|
||||
],
|
||||
'extensionName' => 'Vitec',
|
||||
'iconIdentifier' => 'vitec-import',
|
||||
'routes' => [
|
||||
'_default' => [
|
||||
'target' => ImportController::class . '::indexAction',
|
||||
],
|
||||
'process' => [
|
||||
'target' => ImportController::class . '::processAction',
|
||||
'methods' => ['POST'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'web_vitecogimage' => [
|
||||
'parent' => 'web',
|
||||
'position' => ['after' => 'web_info'],
|
||||
|
||||
@@ -84,6 +84,10 @@ return [
|
||||
'provider' => SvgIconProvider::class,
|
||||
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-marketlist.svg',
|
||||
],
|
||||
'vitec-import' => [
|
||||
'provider' => SvgIconProvider::class,
|
||||
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-import.svg',
|
||||
],
|
||||
'vitec-plugin-customerlogos' => [
|
||||
'provider' => SvgIconProvider::class,
|
||||
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-customerlogos.svg',
|
||||
|
||||
186
packages/vitec/Resources/Private/Templates/Import/Index.html
Normal file
186
packages/vitec/Resources/Private/Templates/Import/Index.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||
data-namespace-typo3-fluid="true">
|
||||
|
||||
<f:layout name="Backend/Default" />
|
||||
|
||||
<f:section name="main">
|
||||
|
||||
<div class="module-docheader-bar module-docheader-bar-navigation">
|
||||
<div class="module-docheader-bar-column-left">
|
||||
<h2 class="t3js-title-inlineedit">VITEC Import — {model.label}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model tabs -->
|
||||
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
||||
<f:for each="{models}" as="m">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
|
||||
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||
</li>
|
||||
</f:for>
|
||||
</ul>
|
||||
|
||||
<!-- Upload -->
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-body">
|
||||
<form action="{f:be.uri(route: 'web_vitecimport.process')}" method="post" enctype="multipart/form-data" class="row row-cols-auto align-items-center g-2">
|
||||
<input type="hidden" name="model" value="{model.key}" />
|
||||
<div class="col">
|
||||
<input type="file" name="csvfile" accept=".csv,text/csv" class="form-control" required="required" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<button type="submit" name="op" value="preview" class="btn btn-primary">Load CSV</button>
|
||||
</div>
|
||||
<div class="col form-text">
|
||||
Delimiter (";" or ",") and encoding (UTF-8 / Windows-1252) are detected automatically.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<f:if condition="{hasCsv}">
|
||||
|
||||
<form action="{f:be.uri(route: 'web_vitecimport.process')}" method="post" id="vitecImportForm">
|
||||
<input type="hidden" name="model" value="{model.key}" />
|
||||
<input type="hidden" name="csvstate" value="{csvstate}" />
|
||||
|
||||
<!-- Mapping -->
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-header">
|
||||
<strong>Field mapping</strong>
|
||||
<f:if condition="{mappingSaved}"><span class="badge bg-info">saved mapping loaded</span></f:if>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped table-sm" style="max-width:900px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CSV column</th>
|
||||
<th>Sample (row 1)</th>
|
||||
<th>Database field</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<f:for each="{columns}" as="col">
|
||||
<tr>
|
||||
<td><code>{col.name}</code></td>
|
||||
<td class="text-muted" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{col.sample}</td>
|
||||
<td>
|
||||
<select name="map[{col.name}]" class="form-select form-select-sm">
|
||||
<option value="">— ignore —</option>
|
||||
<f:for each="{fields}" as="label" key="field">
|
||||
<option value="{field}" {f:if(condition: '{col.target} == {field}', then: 'selected')}>{field} ({label})</option>
|
||||
</f:for>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="row row-cols-auto align-items-center g-2">
|
||||
<div class="col"><label for="identity"><strong>Match records by</strong></label></div>
|
||||
<div class="col">
|
||||
<select name="identity" id="identity" class="form-select form-select-sm">
|
||||
<f:for each="{fields}" as="label" key="field">
|
||||
<option value="{field}" {f:if(condition: '{identity} == {field}', then: 'selected')}>{field}</option>
|
||||
</f:for>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button type="submit" name="op" value="preview" class="btn btn-default">Re-apply mapping</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button type="submit" name="op" value="saveMapping" class="btn btn-default">Save mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified list -->
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-header">
|
||||
<strong>Records</strong>
|
||||
<span class="badge bg-success">{counts.new} new</span>
|
||||
<span class="badge bg-warning text-dark">{counts.update} update</span>
|
||||
<span class="badge bg-secondary">{counts.unchanged} unchanged</span>
|
||||
<span class="badge bg-light text-dark">{counts.dbonly} only in DB</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px;"><input type="checkbox" onclick="document.querySelectorAll('.vitec-row-check').forEach(c => c.checked = this.checked);" /></th>
|
||||
<th style="width:110px;">Status</th>
|
||||
<th style="width:240px;">Record</th>
|
||||
<th>Import data (editable JSON — this is what gets written)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<f:for each="{union}" as="row">
|
||||
<tr>
|
||||
<td>
|
||||
<f:if condition="{row.status} != 'dbonly'">
|
||||
<input type="checkbox" class="vitec-row-check" name="rows[{row.index}][selected]" value="1" {f:if(condition: row.checked, then: 'checked')} />
|
||||
</f:if>
|
||||
</td>
|
||||
<td>
|
||||
<f:switch expression="{row.status}">
|
||||
<f:case value="new"><span class="badge bg-success">new</span></f:case>
|
||||
<f:case value="update"><span class="badge bg-warning text-dark">update</span></f:case>
|
||||
<f:case value="unchanged"><span class="badge bg-secondary">unchanged</span></f:case>
|
||||
<f:defaultCase><span class="badge bg-light text-dark">only in DB</span></f:defaultCase>
|
||||
</f:switch>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{row.label}</strong>
|
||||
<f:if condition="{row.uid}"><br /><small class="text-muted">uid {row.uid}</small></f:if>
|
||||
<f:if condition="{row.status} == 'update'">
|
||||
<br /><small class="text-muted">changes: {row.diff}</small>
|
||||
</f:if>
|
||||
</td>
|
||||
<td>
|
||||
<f:if condition="{row.status} != 'dbonly'">
|
||||
<input type="hidden" name="rows[{row.index}][uid]" value="{row.uid}" />
|
||||
<textarea name="rows[{row.index}][payload]" class="form-control font-monospace" rows="4" style="font-size:11px;">{row.payload}</textarea>
|
||||
</f:if>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="row row-cols-auto align-items-center g-2">
|
||||
<div class="col"><label for="pid"><strong>Storage pid for new records</strong></label></div>
|
||||
<div class="col"><input type="number" name="pid" id="pid" value="{pid}" class="form-control form-control-sm" style="width:110px;" /></div>
|
||||
<div class="col">
|
||||
<button type="submit" name="op" value="apply" class="btn btn-primary"
|
||||
onclick="return confirm('Apply the checked rows to the database?');">Apply checked rows</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</f:if>
|
||||
|
||||
<f:if condition="!{hasCsv}">
|
||||
<f:if condition="{union}">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Current records ({counts.dbonly})</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped table-sm" style="max-width:700px;">
|
||||
<thead><tr><th>uid</th><th>Title</th></tr></thead>
|
||||
<tbody>
|
||||
<f:for each="{union}" as="row">
|
||||
<tr><td>{row.uid}</td><td>{row.label}</td></tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</f:if>
|
||||
</f:if>
|
||||
|
||||
</f:section>
|
||||
</html>
|
||||
5
packages/vitec/Resources/Public/Icons/vitec-import.svg
Normal file
5
packages/vitec/Resources/Public/Icons/vitec-import.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<path d="M16 4v14" stroke="#26358C" stroke-width="3" fill="none"/>
|
||||
<path d="M9 12l7 7 7-7" stroke="#26358C" stroke-width="3" fill="none"/>
|
||||
<path d="M5 22v4a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2v-4" stroke="#ff6a00" stroke-width="3" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -292,3 +292,15 @@ CREATE TABLE tx_vitec_form_submission (
|
||||
delivery_status varchar(20) DEFAULT '' NOT NULL,
|
||||
delivery_error text
|
||||
);
|
||||
|
||||
CREATE TABLE tx_vitec_import_mapping (
|
||||
uid int(11) NOT NULL auto_increment,
|
||||
pid int(11) DEFAULT '0' NOT NULL,
|
||||
tstamp int(11) DEFAULT '0' NOT NULL,
|
||||
crdate int(11) DEFAULT '0' NOT NULL,
|
||||
model varchar(64) DEFAULT '' NOT NULL,
|
||||
identity_field varchar(64) DEFAULT 'slug' NOT NULL,
|
||||
mapping text,
|
||||
PRIMARY KEY (uid),
|
||||
KEY model (model)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user