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 === 'saveAliases' && $csv !== null && $mapping !== null && ($identity ?? '') !== '') { $this->saveAliases( $modelKey, $csv, $mapping, (string)$identity, is_array($body['alias'] ?? null) ? $body['alias'] : [] ); } if ($op === 'apply') { $this->apply($modelKey, $body); } return $this->render($request, $modelKey, $csv, $mapping, $identity); } // --------------------------------------------------- SEO research tab public function seoAction(ServerRequestInterface $request): ResponseInterface { $latest = $this->seoResearchRepository->latest(0); $previous = $this->seoResearchRepository->latest(1); $analysis = $latest !== null ? $this->seoResearchService->analyze($latest['rows'], $previous !== null ? $previous['rows'] : null) : null; $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css'); $view = $this->moduleTemplateFactory->create($request); $view->assignMultiple([ 'models' => $this->registry->all(), 'latest' => $latest, 'previous' => $previous, 'analysis' => $analysis, ]); return $view->renderResponse('Import/Seo'); } public function seoUploadAction(ServerRequestInterface $request): ResponseInterface { $files = $request->getUploadedFiles(); $upload = $files['csvfile'] ?? null; if ($upload !== null && $upload->getError() === UPLOAD_ERR_OK) { $csv = $this->csvReader->parse((string)$upload->getStream()); $rows = $this->seoResearchService->normalizeRows($csv['rows']); if ($rows === []) { $this->flash('No usable rows found - is the "Potential URL" column present?', 'Upload', false); } else { $this->seoResearchRepository->add((string)($upload->getClientFilename() ?? 'upload.csv'), $rows); $this->flash(sprintf('%d rows stored as new delivery.', count($rows)), 'Delivery stored', true); } } else { $this->flash('No file received.', 'Upload', false); } return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303); } /** * Persist hand-made record links from the SEO structure check. Shares the * per-model alias store with the import tabs (MappingRepository), so a * link made in either place serves both - and vitec:create-markets stops * proposing linked rows as new records. Key convention is the import * tab's: lowercased, trimmed CSV name. uid 0 removes a link. */ public function seoAliasesAction(ServerRequestInterface $request): ResponseInterface { $body = (array)$request->getParsedBody(); $modelKey = (string)($body['model'] ?? ''); if ($this->registry->has($modelKey)) { $aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? []; $names = is_array($body['aliaskey'] ?? null) ? $body['aliaskey'] : []; $selected = is_array($body['alias'] ?? null) ? $body['alias'] : []; $changed = 0; foreach ($selected as $index => $selectedUid) { $key = mb_strtolower(trim((string)($names[$index] ?? ''))); if ($key === '') { continue; } $uid = (int)$selectedUid; $current = (int)($aliases[$key] ?? 0); if ($uid > 0 && $uid !== $current) { $aliases[$key] = $uid; $changed++; } elseif ($uid === 0 && $current > 0) { unset($aliases[$key]); $changed++; } } $this->mappingRepository->saveAliases($modelKey, $aliases); $this->flash( sprintf('%d record link(s) changed for %s.', $changed, $modelKey), 'Record links saved', true ); } return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303); } // ------------------------------------------------------------ rendering /** * @param array{columns:array,rows:array>}|null $csv * @param array|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'); } ['rows' => $union, 'candidates' => $aliasCandidates] = $this->buildUnion($model['table'], $fields, $csv, $mapping, $identity, $saved['aliases'] ?? []); $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] ?? '', ]; } $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css'); $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['mapping'] ?? []) !== [], 'aliasCandidates' => $aliasCandidates, '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 $columns * @param array $fields * @return array */ 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. * * Matching order per CSV row: identity field first, then the stored * aliases. Alias candidates are all records the identity match did not * claim - those are what the per-row select in the template offers. * * @param array $fields * @param array{columns:array,rows:array>}|null $csv * @param array $mapping * @param array $aliases * @return array{rows: array>, candidates: array} */ private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity, array $aliases): 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 = []; $directUids = []; $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; $viaAlias = false; if ($uid === 0 && $identityValue !== '') { $aliasUid = (int)($aliases[$identityValue] ?? 0); if ($aliasUid > 0 && isset($records[$aliasUid])) { $uid = $aliasUid; $viaAlias = true; } } if ($uid > 0) { $matchedUids[$uid] = true; if (!$viaAlias) { $directUids[$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', 'viaAlias' => $viaAlias, 'aliasable' => $viaAlias || $status === 'new', 'aliasUid' => $viaAlias ? $uid : 0, ]; } 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, ]; } $candidates = []; foreach ($records as $uid => $record) { if (isset($directUids[$uid])) { continue; } $candidates[] = ['uid' => $uid, 'title' => (string)($record['title'] ?? $uid)]; } usort($candidates, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title'])); return ['rows' => $union, 'candidates' => $candidates]; } /** * Merge the alias selects into the stored aliases. Key is the CSV * identity value (lowercased, like the matcher sees it), value the * record uid; uid 0 removes a link. Untouched keys stay - a CSV that * lacks a row must not lose that row's link. * * @param array{columns:array,rows:array>} $csv * @param array $mapping * @param array $input row index => uid, from the form */ private function saveAliases(string $modelKey, array $csv, array $mapping, string $identity, array $input): void { $aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? []; $changed = 0; foreach ($input as $rowIndex => $selectedUid) { $raw = $csv['rows'][(int)$rowIndex] ?? null; if ($raw === null) { continue; } $payload = []; foreach ($mapping as $column => $field) { $payload[$field] = (string)($raw[$column] ?? ''); } $key = mb_strtolower(trim((string)($payload[$identity] ?? ''))); if ($key === '') { continue; } $uid = (int)$selectedUid; $current = (int)($aliases[$key] ?? 0); if ($uid > 0 && $uid !== $current) { $aliases[$key] = $uid; $changed++; } elseif ($uid === 0 && $current > 0) { unset($aliases[$key]); $changed++; } } $this->mappingRepository->saveAliases($modelKey, $aliases); $this->flash( sprintf('%d record link(s) changed, %d stored for this model in total.', $changed, count($aliases)), 'Record links saved', true ); } // ---------------------------------------------------------------- apply /** @param array $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); } }