, rows_total?:int, code?:string} */ public function analyze(string $path, int $maxRows): array { if (!is_file($path) || !is_readable($path)) { return ['ok' => false, 'code' => 'invalid_file_type']; } $delimiter = $this->detectDelimiter($path); $headerMeta = $this->readHeader($path, $delimiter); if (!$headerMeta['ok']) { $code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header'; return ['ok' => false, 'code' => $code]; } $headers = $headerMeta['headers'] ?? []; $headerLine = (int) ($headerMeta['header_line'] ?? 1); if (!$headers) { return ['ok' => false, 'code' => 'invalid_csv_header']; } $rowsTotal = $this->countRows($path, $delimiter, $headers, $headerLine); if ($rowsTotal <= 0) { return ['ok' => false, 'code' => 'empty_csv']; } if ($rowsTotal > $maxRows) { return ['ok' => false, 'code' => 'max_rows_exceeded']; } return [ 'ok' => true, 'delimiter' => $delimiter, 'headers' => $headers, 'rows_total' => $rowsTotal, ]; } /** * @param array $headers */ public function streamRows(string $path, string $delimiter, array $headers, callable $callback): void { $headerMeta = $this->readHeader($path, $delimiter); if (!$headerMeta['ok']) { return; } $headerLine = (int) ($headerMeta['header_line'] ?? 1); $handle = @fopen($path, 'rb'); if (!$handle) { return; } $lineNumber = 0; $rowIndex = 0; while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { $lineNumber++; if ($lineNumber <= $headerLine) { continue; } $row = $this->normalizeRowColumns($row, count($headers)); if ($this->isEmptyRow($row)) { continue; } $rowIndex++; $assoc = []; foreach ($headers as $index => $header) { $assoc[$header] = $this->cleanCell($row[$index] ?? ''); } $callback($assoc, $lineNumber, $rowIndex); } fclose($handle); } // Sample-based detection — reading 5 lines is enough to identify the delimiter without scanning the full file. private function detectDelimiter(string $path): string { $sample = ''; $handle = @fopen($path, 'rb'); if ($handle) { for ($i = 0; $i < 5; $i++) { $line = fgets($handle); if ($line === false) { break; } $sample .= $line; } fclose($handle); } $bestDelimiter = ','; $bestScore = -1; foreach (self::DELIMITERS as $delimiter) { $score = substr_count($sample, $delimiter); if ($score > $bestScore) { $bestScore = $score; $bestDelimiter = $delimiter; } } return $bestDelimiter; } /** * @return array{ok:bool, headers?:array, header_line?:int, code?:string} */ private function readHeader(string $path, string $delimiter): array { $handle = @fopen($path, 'rb'); if (!$handle) { return ['ok' => false, 'code' => 'invalid_csv_header']; } $lineNumber = 0; while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { $lineNumber++; $row = $this->normalizeRowColumns($row, count($row)); if ($this->isEmptyRow($row)) { continue; } $headers = []; foreach ($row as $index => $cell) { $value = $this->cleanCell((string) $cell); if ($index === 0) { $value = $this->stripBom($value); } $headers[] = trim($value); } fclose($handle); if (!$headers) { return ['ok' => false, 'code' => 'invalid_csv_header']; } foreach ($headers as $headerValue) { if ($headerValue === '') { return ['ok' => false, 'code' => 'invalid_csv_header']; } } // Case-insensitive duplicate check — "Email" and "email" in the same header row are ambiguous. $normalizedKeys = array_map([$this, 'lower'], $headers); if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) { return ['ok' => false, 'code' => 'invalid_csv_header']; } return ['ok' => true, 'headers' => $headers, 'header_line' => $lineNumber]; } fclose($handle); return ['ok' => false, 'code' => 'empty_csv']; } /** * @param array $row * @return array */ private function normalizeRowColumns(array $row, int $size): array { $normalized = []; foreach ($row as $cell) { $normalized[] = $this->cleanCell((string) $cell); } // Pad short rows and trim over-long rows so every row has exactly as many cells as headers. $current = count($normalized); if ($current < $size) { $normalized = array_pad($normalized, $size, ''); } elseif ($current > $size) { $normalized = array_slice($normalized, 0, $size); } return $normalized; } /** * @param array $headers */ private function countRows(string $path, string $delimiter, array $headers, int $headerLine): int { $count = 0; $handle = @fopen($path, 'rb'); if (!$handle) { return 0; } $lineNumber = 0; while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { $lineNumber++; if ($lineNumber <= $headerLine) { continue; } $row = $this->normalizeRowColumns($row, count($headers)); if ($this->isEmptyRow($row)) { continue; } $count++; } fclose($handle); return $count; } private function isEmptyRow(array $row): bool { foreach ($row as $cell) { if (trim((string) $cell) !== '') { return false; } } return true; } private function stripBom(string $value): string { if (str_starts_with($value, "\xEF\xBB\xBF")) { return substr($value, 3); } return $value; } // Remove null bytes (defense against null byte injection) and UTF-8 BOM from each cell. private function cleanCell(string $value): string { $value = str_replace("\0", '', $value); $value = $this->stripBom($value); return trim($value); } private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); } return strtolower($value); } }