Files
skyview.astronomiemuseum.de/scripts/import/import_mpc_minorplanets.php
T
2026-03-31 09:44:27 +02:00

819 lines
25 KiB
PHP

<?php
declare(strict_types=1);
/**
* Importiert MPCORB.DAT in die Tabelle minorplanets_mpc.
*
* Nutzung:
* php scripts/import/import_mpc_minorplanets.php
* php scripts/import/import_mpc_minorplanets.php C:\pfad\zu\MPCORB.DAT
*
* Erwartet DB-Zugangsdaten aus config/database.php.
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
set_time_limit(0);
$rootDir = dirname(__DIR__, 2);
$config = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'database.php';
if (!is_array($config)) {
writeMessage('config/database.php muss ein Array mit DB-Zugangsdaten zurueckgeben.', true);
exit(1);
}
$config = normalizeDbConfig($config);
foreach (['host', 'dbname', 'user', 'pass'] as $requiredKey) {
if (!array_key_exists($requiredKey, $config) || $config[$requiredKey] === '') {
writeMessage("DB-Konfiguration unvollstaendig: {$requiredKey} fehlt.", true);
exit(1);
}
}
$defaultDatPath = $rootDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'import' . DIRECTORY_SEPARATOR . 'MPCORB.DAT';
$datPath = $argv[1] ?? $defaultDatPath;
if (!is_file($datPath)) {
writeMessage("MPCORB.DAT nicht gefunden: {$datPath}", true);
exit(1);
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
(int) $config['port'],
$config['dbname'],
$config['charset']
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$insertSql = <<<'SQL'
INSERT INTO `minorplanets_mpc` (
`desig_packed`,
`mp_number`,
`absolute_magnitude_h`,
`slope_g`,
`epoch_packed`,
`mean_anomaly_deg`,
`arg_perihelion_deg`,
`ascending_node_deg`,
`inclination_deg`,
`eccentricity`,
`mean_motion_deg_per_day`,
`semimajor_axis_au`,
`uncertainty_u`,
`reference_code`,
`num_observations`,
`num_oppositions`,
`arc_text`,
`rms_residual`,
`perturbers_1`,
`perturbers_2`,
`orbit_computer`,
`hex_flags`,
`designation_text`,
`last_obs_compact`,
`last_obs_date`,
`raw_record`
) VALUES (
:desig_packed,
:mp_number,
:absolute_magnitude_h,
:slope_g,
:epoch_packed,
:mean_anomaly_deg,
:arg_perihelion_deg,
:ascending_node_deg,
:inclination_deg,
:eccentricity,
:mean_motion_deg_per_day,
:semimajor_axis_au,
:uncertainty_u,
:reference_code,
:num_observations,
:num_oppositions,
:arc_text,
:rms_residual,
:perturbers_1,
:perturbers_2,
:orbit_computer,
:hex_flags,
:designation_text,
:last_obs_compact,
:last_obs_date,
:raw_record
)
SQL;
$statement = $pdo->prepare($insertSql);
$isCli = PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg';
$statePath = $rootDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'import' . DIRECTORY_SEPARATOR . 'import_mpc_minorplanets.state.json';
if ($isCli) {
runFullImport($pdo, $statement, $datPath);
exit(0);
}
runWebImportChunk($pdo, $statement, $datPath, $statePath);
exit(0);
function runFullImport(PDO $pdo, PDOStatement $statement, string $datPath): void
{
writeMessage('Stelle sicher, dass desig_packed case-sensitive gespeichert wird...');
ensureCaseSensitiveDesigPackedColumn($pdo);
$minorplanetAssignments = backupUserMinorplanetAssignments($pdo);
writeMessage('Gesicherte Nutzer-Kleinplaneten-Zuordnungen: ' . count($minorplanetAssignments));
writeMessage('Leere app_user_minorplanets und minorplanets_mpc und setze AUTO_INCREMENT zurueck...');
clearTableWithRetry($pdo, 'app_user_minorplanets');
resetAutoIncrementWithRetry($pdo, 'app_user_minorplanets');
clearTableWithRetry($pdo, 'minorplanets_mpc');
resetAutoIncrementWithRetry($pdo, 'minorplanets_mpc');
$state = [
'processed' => 0,
'imported' => 0,
'skipped' => 0,
'in_data_section' => false,
'byte_offset' => 0,
'started_at' => microtime(true),
'total_data_lines' => countMpcorbDataLines($datPath),
'minorplanet_assignments' => $minorplanetAssignments,
];
if ($state['total_data_lines'] > 0) {
writeMessage("Import startet. Erwartete Datensaetze: {$state['total_data_lines']}.");
} else {
writeMessage('Import startet. Gesamtzahl der Datensaetze konnte nicht ermittelt werden.');
}
$result = processImportChunk($pdo, $statement, $datPath, $state, PHP_INT_MAX, 10000, 5000);
if (!$result['completed']) {
writeMessage('Import wurde unerwartet nicht abgeschlossen.', true);
exit(1);
}
writeMessage("Import abgeschlossen. Verarbeitet: {$result['state']['processed']}, importiert: {$result['state']['imported']}, uebersprungen: {$result['state']['skipped']}.");
restoreUserMinorplanetAssignments($pdo, $result['state']['minorplanet_assignments'] ?? []);
writeDatabaseCountSummary($pdo, $result['state']['imported']);
}
function runWebImportChunk(PDO $pdo, PDOStatement $statement, string $datPath, string $statePath): void
{
$resetRequested = isset($_GET['reset']) && $_GET['reset'] === '1';
$batchLineLimit = max(1000, min(50000, (int) ($_GET['batch'] ?? 20000)));
$progressInterval = max(1000, min($batchLineLimit, (int) ($_GET['progress'] ?? 5000)));
$commitInterval = max(1000, min($batchLineLimit, (int) ($_GET['commit'] ?? 5000)));
if ($resetRequested || !is_file($statePath)) {
writeMessage('Initialisiere Web-Import...');
ensureCaseSensitiveDesigPackedColumn($pdo);
$minorplanetAssignments = backupUserMinorplanetAssignments($pdo);
writeMessage('Gesicherte Nutzer-Kleinplaneten-Zuordnungen: ' . count($minorplanetAssignments));
clearTableWithRetry($pdo, 'app_user_minorplanets');
resetAutoIncrementWithRetry($pdo, 'app_user_minorplanets');
clearTableWithRetry($pdo, 'minorplanets_mpc');
resetAutoIncrementWithRetry($pdo, 'minorplanets_mpc');
$state = [
'processed' => 0,
'imported' => 0,
'skipped' => 0,
'in_data_section' => false,
'byte_offset' => 0,
'started_at' => microtime(true),
'total_data_lines' => countMpcorbDataLines($datPath),
'status' => 'running',
'minorplanet_assignments' => $minorplanetAssignments,
];
saveImportState($statePath, $state);
writeMessage("Web-Import vorbereitet. Erwartete Datensaetze: {$state['total_data_lines']}.");
} else {
$state = loadImportState($statePath);
if (($state['status'] ?? 'running') === 'completed') {
writeMessage('Vorheriger Import ist bereits abgeschlossen. Mit ?reset=1 neu starten.');
writeDatabaseCountSummary($pdo, (int) ($state['imported'] ?? 0));
return;
}
writeMessage("Setze Web-Import fort bei Datensatz {$state['processed']}...");
}
$result = processImportChunk($pdo, $statement, $datPath, $state, $batchLineLimit, $progressInterval, $commitInterval);
$state = $result['state'];
if ($result['completed']) {
restoreUserMinorplanetAssignments($pdo, $state['minorplanet_assignments'] ?? []);
$state['status'] = 'completed';
saveImportState($statePath, $state);
writeMessage("Import abgeschlossen. Verarbeitet: {$state['processed']}, importiert: {$state['imported']}, uebersprungen: {$state['skipped']}.");
writeDatabaseCountSummary($pdo, (int) $state['imported']);
writeRestartHint();
return;
}
$state['status'] = 'running';
saveImportState($statePath, $state);
writeMessage("Chunk fertig. Naechster Start bei Datensatz {$state['processed']}.");
writeMessage('Naechster Chunk wird automatisch geladen...');
writeAutoContinueScript($batchLineLimit, $progressInterval, $commitInterval);
}
function processImportChunk(
PDO $pdo,
PDOStatement $statement,
string $datPath,
array $state,
int $lineLimit,
int $progressInterval,
int $commitInterval
): array {
$handle = fopen($datPath, 'rb');
if ($handle === false) {
writeMessage("MPCORB.DAT konnte nicht geoeffnet werden: {$datPath}", true);
exit(1);
}
if (($state['byte_offset'] ?? 0) > 0) {
fseek($handle, (int) $state['byte_offset']);
}
$processedInChunk = 0;
$batchProcessed = 0;
$completed = true;
$pdo->beginTransaction();
try {
while (($line = fgets($handle)) !== false) {
$state['byte_offset'] = ftell($handle);
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
if (!($state['in_data_section'] ?? false)) {
if (preg_match('/^-{20,}$/', trim($line)) === 1) {
$state['in_data_section'] = true;
}
continue;
}
$state['processed']++;
$processedInChunk++;
$parseResult = parseMpcorbLine($line);
if ($parseResult['record'] === null) {
$state['skipped']++;
writeMessage(formatSkipDetail((int) $state['processed'], $line, $parseResult['designation'], $parseResult['reason']), true);
} else {
try {
$statement->execute($parseResult['record']);
$state['imported']++;
$batchProcessed++;
} catch (Throwable $rowException) {
$state['skipped']++;
writeMessage(formatSkipDetail(
(int) $state['processed'],
$line,
$parseResult['designation'],
'DB-Fehler: ' . $rowException->getMessage()
), true);
}
}
if ($batchProcessed >= $commitInterval) {
$pdo->commit();
writeMessage("Zwischenstand gespeichert bei {$state['processed']} verarbeiteten Datensaetzen.");
$pdo->beginTransaction();
$batchProcessed = 0;
}
if ($state['processed'] % $progressInterval === 0) {
writeMessage(buildProgressMessage(
(int) $state['processed'],
(int) $state['imported'],
(int) $state['skipped'],
(int) ($state['total_data_lines'] ?? 0),
(float) ($state['started_at'] ?? microtime(true))
));
}
if ($processedInChunk >= $lineLimit) {
$completed = false;
break;
}
}
if ($pdo->inTransaction()) {
$pdo->commit();
}
} catch (Throwable $e) {
fclose($handle);
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
writeMessage('Importfehler: ' . $e->getMessage(), true);
exit(1);
}
if (feof($handle)) {
$completed = true;
}
fclose($handle);
return [
'completed' => $completed,
'state' => $state,
];
}
function loadImportState(string $statePath): array
{
$json = @file_get_contents($statePath);
$data = is_string($json) ? json_decode($json, true) : null;
if (!is_array($data)) {
return [
'processed' => 0,
'imported' => 0,
'skipped' => 0,
'in_data_section' => false,
'byte_offset' => 0,
'started_at' => microtime(true),
'total_data_lines' => 0,
'status' => 'running',
];
}
return $data;
}
function saveImportState(string $statePath, array $state): void
{
file_put_contents(
$statePath,
json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
function writeDatabaseCountSummary(PDO $pdo, int $imported): void
{
$dbRowCount = (int) $pdo->query('SELECT COUNT(*) FROM `minorplanets_mpc`')->fetchColumn();
writeMessage("Kontrolle Datenbank: minorplanets_mpc enthaelt {$dbRowCount} Zeilen.");
if ($dbRowCount !== $imported) {
writeMessage(
"WARNUNG: Importzaehler ({$imported}) und echte Datenbankzeilen ({$dbRowCount}) unterscheiden sich.",
true
);
}
}
function backupUserMinorplanetAssignments(PDO $pdo): array
{
$sql = <<<'SQL'
SELECT
aum.`user_id`,
aum.`is_favorite`,
aum.`created_at`,
mp.`desig_packed`
FROM `app_user_minorplanets` aum
INNER JOIN `minorplanets_mpc` mp
ON mp.`id` = aum.`minorplanet_id`
ORDER BY aum.`user_id` ASC, aum.`minorplanet_id` ASC
SQL;
return $pdo->query($sql)->fetchAll() ?: [];
}
function restoreUserMinorplanetAssignments(PDO $pdo, array $assignments): void
{
if ($assignments === []) {
writeMessage('Keine Nutzer-Kleinplaneten-Zuordnungen zum Wiederherstellen vorhanden.');
return;
}
$lookupStatement = $pdo->prepare('SELECT `id` FROM `minorplanets_mpc` WHERE `desig_packed` = :desig_packed LIMIT 1');
$insertStatement = $pdo->prepare(
'INSERT INTO `app_user_minorplanets` (`user_id`, `minorplanet_id`, `is_favorite`, `created_at`)
VALUES (:user_id, :minorplanet_id, :is_favorite, :created_at)'
);
$restored = 0;
$missing = 0;
foreach ($assignments as $assignment) {
$desigPacked = (string) ($assignment['desig_packed'] ?? '');
if ($desigPacked === '') {
$missing++;
continue;
}
$lookupStatement->execute([
':desig_packed' => $desigPacked,
]);
$minorplanetId = $lookupStatement->fetchColumn();
if ($minorplanetId === false) {
$missing++;
writeMessage("Zuordnung konnte nicht wiederhergestellt werden: {$desigPacked}", true);
continue;
}
$insertStatement->execute([
':user_id' => (int) $assignment['user_id'],
':minorplanet_id' => (int) $minorplanetId,
':is_favorite' => (int) $assignment['is_favorite'],
':created_at' => (string) $assignment['created_at'],
]);
$restored++;
}
writeMessage("Nutzer-Kleinplaneten-Zuordnungen wiederhergestellt: {$restored}.");
if ($missing > 0) {
writeMessage("Nicht wiederherstellbare Zuordnungen: {$missing}.", true);
}
}
function writeAutoContinueScript(int $batchLineLimit, int $progressInterval, int $commitInterval): void
{
$query = http_build_query([
'batch' => $batchLineLimit,
'progress' => $progressInterval,
'commit' => $commitInterval,
]);
echo '<script>setTimeout(function(){ window.location.search = ' . json_encode('?' . $query) . '; }, 800);</script>' . "\n";
echo '<noscript><p><a href="?' . htmlspecialchars($query, ENT_QUOTES, 'UTF-8') . '">Naechsten Chunk starten</a></p></noscript>' . "\n";
}
function writeRestartHint(): void
{
echo '<p><a href="?reset=1">Import neu starten</a></p>' . "\n";
}
function countMpcorbDataLines(string $datPath): int
{
$handle = fopen($datPath, 'rb');
if ($handle === false) {
return 0;
}
$inDataSection = false;
$count = 0;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
if (!$inDataSection) {
if (preg_match('/^-{20,}$/', trim($line)) === 1) {
$inDataSection = true;
}
continue;
}
$count++;
}
fclose($handle);
return $count;
}
function clearTableWithRetry(PDO $pdo, string $tableName, int $maxAttempts = 5, int $waitMilliseconds = 1500): void
{
$sql = "DELETE FROM `{$tableName}`";
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
$pdo->exec($sql);
return;
} catch (PDOException $e) {
if (!isLockTimeoutException($e) || $attempt === $maxAttempts) {
throw $e;
}
writeMessage("Warte auf Sperre beim Leeren von {$tableName} (Versuch {$attempt}/{$maxAttempts})...", true);
usleep($waitMilliseconds * 1000);
}
}
}
function ensureCaseSensitiveDesigPackedColumn(PDO $pdo): void
{
$sql = <<<'SQL'
ALTER TABLE `minorplanets_mpc`
MODIFY `desig_packed` VARCHAR(16)
CHARACTER SET utf8mb4
COLLATE utf8mb4_bin
NOT NULL
COMMENT 'Spalte Des''n aus MPCORB.DAT, z. B. 00001 oder K07Tf8A'
SQL;
$pdo->exec($sql);
}
function resetAutoIncrementWithRetry(PDO $pdo, string $tableName, int $maxAttempts = 5, int $waitMilliseconds = 1500): void
{
$sql = "ALTER TABLE `{$tableName}` AUTO_INCREMENT = 1";
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
$pdo->exec($sql);
return;
} catch (PDOException $e) {
if (!isLockTimeoutException($e) || $attempt === $maxAttempts) {
throw $e;
}
writeMessage("Warte auf Sperre beim Zuruecksetzen von AUTO_INCREMENT fuer {$tableName} (Versuch {$attempt}/{$maxAttempts})...", true);
usleep($waitMilliseconds * 1000);
}
}
}
function isLockTimeoutException(PDOException $e): bool
{
$message = $e->getMessage();
return str_contains($message, '1205') || stripos($message, 'Lock wait timeout exceeded') !== false;
}
function buildProgressMessage(int $processed, int $imported, int $skipped, int $totalDataLines, float $startedAt): string
{
$elapsedSeconds = max(microtime(true) - $startedAt, 0.001);
$rate = $processed / $elapsedSeconds;
$percentage = $totalDataLines > 0 ? ($processed / $totalDataLines) * 100 : null;
$remainingSeconds = ($totalDataLines > 0 && $rate > 0)
? max(($totalDataLines - $processed) / $rate, 0)
: null;
$parts = [
'Status',
"verarbeitet {$processed}" . ($totalDataLines > 0 ? " von {$totalDataLines}" : ''),
];
if ($percentage !== null) {
$parts[] = sprintf('%.2f %%', $percentage);
}
$parts[] = "importiert {$imported}";
$parts[] = "uebersprungen {$skipped}";
$parts[] = sprintf('%.0f Datensaetze/s', $rate);
$parts[] = 'Laufzeit ' . formatDuration($elapsedSeconds);
if ($remainingSeconds !== null) {
$parts[] = 'Rest ca. ' . formatDuration($remainingSeconds);
}
return implode(' | ', $parts);
}
function formatDuration(float $seconds): string
{
$seconds = (int) round($seconds);
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
$secs = $seconds % 60;
if ($hours > 0) {
return sprintf('%02d:%02d:%02d', $hours, $minutes, $secs);
}
return sprintf('%02d:%02d', $minutes, $secs);
}
function parseMpcorbLine(string $line): array
{
$trimmed = rtrim($line);
$padded = str_pad($trimmed, 202, ' ');
$desigPacked = readFixedField($padded, 1, 7);
$epochPacked = readFixedField($padded, 21, 25);
if ($desigPacked === null || $epochPacked === null) {
return [
'record' => null,
'designation' => guessDesignationFromRawLine($trimmed),
'reason' => 'Pflichtfelder in den festen MPC-Spalten fehlen',
];
}
$designationText = readFixedField($padded, 167, 194);
return [
'record' => [
':desig_packed' => $desigPacked,
':mp_number' => extractMinorPlanetNumber((string) $designationText, $desigPacked),
':absolute_magnitude_h' => toNullableDecimal((string) readFixedField($padded, 9, 13)),
':slope_g' => toNullableDecimal((string) readFixedField($padded, 15, 19)),
':epoch_packed' => $epochPacked,
':mean_anomaly_deg' => (float) readFixedField($padded, 27, 35),
':arg_perihelion_deg' => (float) readFixedField($padded, 38, 46),
':ascending_node_deg' => (float) readFixedField($padded, 49, 57),
':inclination_deg' => (float) readFixedField($padded, 60, 68),
':eccentricity' => (float) readFixedField($padded, 71, 79),
':mean_motion_deg_per_day' => (float) readFixedField($padded, 81, 91),
':semimajor_axis_au' => (float) readFixedField($padded, 93, 103),
':uncertainty_u' => nullableTrim(readFixedField($padded, 106, 106)),
':reference_code' => nullableTrim(readFixedField($padded, 108, 116)),
':num_observations' => toNullableInt(readFixedField($padded, 118, 122)),
':num_oppositions' => toNullableInt(readFixedField($padded, 124, 126)),
':arc_text' => nullableTrim(readFixedField($padded, 128, 136)),
':rms_residual' => toNullableDecimal((string) readFixedField($padded, 138, 141)),
':perturbers_1' => nullableTrim(readFixedField($padded, 143, 145)),
':perturbers_2' => nullableTrim(readFixedField($padded, 147, 149)),
':orbit_computer' => nullableTrim(readFixedField($padded, 151, 160)),
':hex_flags' => nullableTrim(readFixedField($padded, 162, 165)),
':designation_text' => $designationText,
':last_obs_compact' => readFixedField($padded, 195, 202),
':last_obs_date' => compactDateToSql(readFixedField($padded, 195, 202)),
':raw_record' => $trimmed,
],
'designation' => $designationText ?? guessDesignationFromRawLine($trimmed),
'reason' => null,
];
}
function readFixedField(string $line, int $startColumn, int $endColumn): ?string
{
$offset = $startColumn - 1;
$length = $endColumn - $startColumn + 1;
$value = substr($line, $offset, $length);
if ($value === false) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
function guessDesignationFromRawLine(string $line): string
{
if ($line === '') {
return '[leere Zeile]';
}
if (preg_match('/\((\d+)\)\s+([^\r\n]+?)\s+\d{8}$/', $line, $matches) === 1) {
return '(' . $matches[1] . ') ' . trim($matches[2]);
}
if (preg_match('/([A-Za-z0-9\-\(\)\/ ]+)\s+\d{8}$/', $line, $matches) === 1) {
return trim($matches[1]);
}
return substr($line, 0, 80);
}
function formatSkipDetail(int $lineNumber, string $rawLine, ?string $designation, ?string $reason): string
{
$objectLabel = $designation !== null && trim($designation) !== ''
? trim($designation)
: guessDesignationFromRawLine(rtrim($rawLine));
$reasonText = $reason !== null && trim($reason) !== ''
? trim($reason)
: 'Unbekannter Grund';
return "Datensatz {$lineNumber}: {$objectLabel} -> {$reasonText}";
}
function extractMinorPlanetNumber(string $designationText, string $desigPacked): ?int
{
if (preg_match('/^\((\d+)\)/', $designationText, $matches) === 1) {
return (int) $matches[1];
}
if (ctype_digit($desigPacked)) {
return (int) ltrim($desigPacked, '0');
}
return null;
}
function compactDateToSql(?string $value): ?string
{
if ($value === null || preg_match('/^\d{8}$/', $value) !== 1) {
return null;
}
$year = substr($value, 0, 4);
$month = substr($value, 4, 2);
$day = substr($value, 6, 2);
if (!checkdate((int) $month, (int) $day, (int) $year)) {
return null;
}
return $year . '-' . $month . '-' . $day;
}
function toNullableDecimal(string $value): ?float
{
$value = trim($value);
if ($value === '') {
return null;
}
return is_numeric($value) ? (float) $value : null;
}
function toNullableInt(?string $value): ?int
{
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '' || !preg_match('/^-?\d+$/', $value)) {
return null;
}
return (int) $value;
}
function nullableTrim(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
function normalizeDbConfig(array $config): array
{
if (isset($config['password']) && !isset($config['pass'])) {
$config['pass'] = $config['password'];
}
if (isset($config['database']) && !isset($config['dbname'])) {
$config['dbname'] = $config['database'];
}
if (isset($config['db']) && !isset($config['dbname'])) {
$config['dbname'] = $config['db'];
}
if (!isset($config['port'])) {
$config['port'] = 3306;
}
if (!isset($config['charset'])) {
$config['charset'] = 'utf8mb4';
}
return $config;
}
function writeMessage(string $message, bool $isError = false): void
{
$line = $message . PHP_EOL;
$isCli = PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg';
if ($isCli && $isError && defined('STDERR')) {
fwrite(STDERR, $line);
if (function_exists('fflush')) {
fflush(STDERR);
}
return;
}
if ($isCli && !$isError && defined('STDOUT')) {
fwrite(STDOUT, $line);
if (function_exists('fflush')) {
fflush(STDOUT);
}
return;
}
echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . "<br>\n";
if (function_exists('ob_flush')) {
@ob_flush();
}
if (function_exists('flush')) {
flush();
}
}