#: one cell of the n-th occurrence of a component * (cell = title | body | cta) * m: meta block (url, primaryKeyword, intent, seoApproach) * s:
one column of the "Page SEO Check" value row * * They survive across deliveries because the vocabulary is stable - that is * what makes the saved mapping reusable for every next product. */ final class ProductXlsxReader { /** * Explicitly marked "Internal note - not website copy" in the deliveries; * never offered as an import source. */ private const HIDDEN_COMPONENTS = ['Technical Gap / Validation Note']; /** Meta-block labels (column A, lowercased) => meta keys. */ private const META_LABELS = [ 'url' => 'url', 'primary keyword' => 'primaryKeyword', 'intent' => 'intent', 'seo approach' => 'seoApproach', ]; /** * @return array{ * pageType: string, * meta: array, * components: array>, * seo: array * } */ public function parse(string $filePath): array { $reader = IOFactory::createReaderForFile($filePath); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($filePath); $sheet = $spreadsheet->getSheet(0); // Formatting flags off: raw values, no calculated formatting - the // deliveries are pure text. $rows = $sheet->toArray(null, false, false, false); // The header row is FOUND, not assumed at row 9: the one whose column B // says "Component". $headerIndex = null; foreach ($rows as $i => $row) { if (trim((string)($row[1] ?? '')) === 'Component') { $headerIndex = $i; break; } } $meta = []; $components = []; if ($headerIndex !== null) { foreach (array_slice($rows, 0, $headerIndex) as $row) { $label = strtolower(trim((string)($row[0] ?? ''))); $value = trim((string)($row[1] ?? '')); if ($label !== '' && $value !== '' && isset(self::META_LABELS[$label])) { $meta[self::META_LABELS[$label]] = $value; } } $occurrences = []; foreach (array_slice($rows, $headerIndex + 1) as $row) { $component = trim((string)($row[1] ?? '')); if ($component === '') { continue; } $occurrences[$component] = ($occurrences[$component] ?? 0) + 1; $components[] = [ 'order' => (int)($row[0] ?? 0), 'component' => $component, 'occurrence' => $occurrences[$component], 'title' => trim((string)($row[2] ?? '')), 'body' => trim((string)($row[3] ?? '')), 'cta' => trim((string)($row[4] ?? '')), 'lengthNote' => trim((string)($row[5] ?? '')), 'seoNote' => trim((string)($row[6] ?? '')), ]; } } $seo = []; $seoSheet = $spreadsheet->getSheetByName('Page SEO Check') ?? ($spreadsheet->getSheetCount() > 1 ? $spreadsheet->getSheet(1) : null); if ($seoSheet !== null) { $seoRows = $seoSheet->toArray(null, false, false, false); $headers = $seoRows[0] ?? []; $values = $seoRows[1] ?? []; foreach ($headers as $i => $header) { $header = trim((string)$header); $value = trim((string)($values[$i] ?? '')); if ($header !== '' && $value !== '') { $seo[$header] = $value; } } } $spreadsheet->disconnectWorksheets(); return [ 'pageType' => $this->detectPageType($components), 'meta' => $meta, 'components' => $components, 'seo' => $seo, ]; } /** * The selectable sources of a parsed workbook, grouped for the dropdown. * Every entry: selector, label, value (full) and preview (shortened). * * @param array $parsed * @return array> */ public function sources(array $parsed): array { $groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []]; foreach ($parsed['meta'] ?? [] as $key => $value) { $groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value); } foreach ($parsed['components'] ?? [] as $component) { $name = (string)$component['component']; if (in_array($name, self::HIDDEN_COMPONENTS, true)) { continue; } $suffix = ((int)$component['occurrence']) > 1 || $this->occursMultipleTimes($parsed, $name) ? ' #' . $component['occurrence'] : ''; foreach (['title' => 'Title', 'body' => 'Body', 'cta' => 'CTA'] as $cell => $cellLabel) { $value = (string)($component[$cell] ?? ''); if ($value === '') { continue; } $groups['Components'][] = $this->entry( 'c:' . $name . '#' . $component['occurrence'] . ':' . $cell, $name . $suffix . ' · ' . $cellLabel, $value ); } } foreach ($parsed['seo'] ?? [] as $header => $value) { $groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value); } return array_filter($groups, static fn(array $g): bool => $g !== []); } /** * Resolves one stored selector against a parsed workbook. null = the * selector points at nothing in THIS file (component missing or cell * empty) - the caller shows that instead of silently writing ''. * * @param array $parsed */ public function valueFor(array $parsed, string $selector): ?string { if (str_starts_with($selector, 'm:')) { $value = $parsed['meta'][substr($selector, 2)] ?? null; return is_string($value) && $value !== '' ? $value : null; } if (str_starts_with($selector, 's:')) { $value = $parsed['seo'][substr($selector, 2)] ?? null; return is_string($value) && $value !== '' ? $value : null; } if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) { foreach ($parsed['components'] ?? [] as $component) { if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) { $value = (string)($component[$m[3]] ?? ''); return $value !== '' ? $value : null; } } } return null; } /** @param array> $components */ private function detectPageType(array $components): string { $names = array_column($components, 'component'); if (in_array('Product Introduction', $names, true)) { return 'product'; } if (in_array('Category Introduction', $names, true)) { return 'category'; } return 'unknown'; } /** @param array $parsed */ private function occursMultipleTimes(array $parsed, string $name): bool { $count = 0; foreach ($parsed['components'] ?? [] as $component) { if ((string)$component['component'] === $name && ++$count > 1) { return true; } } return false; } /** @return array{selector:string,label:string,value:string,preview:string} */ private function entry(string $selector, string $label, string $value): array { $flat = preg_replace('/\s+/u', ' ', $value) ?? $value; return [ 'selector' => $selector, 'label' => $label, 'value' => $value, 'preview' => mb_strlen($flat) > 60 ? mb_substr($flat, 0, 57) . '…' : $flat, ]; } }