From 341c83ad576e0d9ef97b9878efea4498f14f86fe Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 17 Oct 2025 14:28:35 +0300 Subject: [PATCH] Fix command line printer and verbosity --- .../Transmogrify/config/schema/tables.json | 12 +- .../src/Command/TransmogrifyCommand.php | 66 +- .../TransmogrifySourceToTargetCommand.php | 1178 ++++++++++------- .../src/Lib/Traits/HookRunnersTrait.php | 10 +- .../src/Lib/Traits/ManageDefaultsTrait.php | 2 +- .../src/Lib/Traits/RowTransformationTrait.php | 8 +- .../src/Lib/Traits/TypeMapperTrait.php | 6 +- .../src/Lib/Util/CommandLinePrinter.php | 412 +++++- .../src/Lib/Util/RawSqlQueries.php | 6 +- 9 files changed, 1107 insertions(+), 593 deletions(-) diff --git a/app/plugins/Transmogrify/config/schema/tables.json b/app/plugins/Transmogrify/config/schema/tables.json index 36ecf0939..74a234bc0 100644 --- a/app/plugins/Transmogrify/config/schema/tables.json +++ b/app/plugins/Transmogrify/config/schema/tables.json @@ -101,7 +101,7 @@ "servers": { "source": "cm_servers", "displayField": "description", - "addChangelog": false, + "addChangelog": true, "cache": ["co_id", "status"], "fieldMap": { "plugin": "&mapServerTypeToPlugin", @@ -111,7 +111,7 @@ "http_servers": { "source": "cm_http_servers", "displayField": "serverurl", - "addChangelog": false, + "addChangelog": true, "booleans": [ "ssl_verify_host", "ssl_verify_peer" @@ -126,7 +126,7 @@ "oauth2_servers": { "source": "cm_oauth2_servers", "displayField": "serverurl", - "addChangelog": false, + "addChangelog": true, "cache": ["server_id"], "booleans": ["access_token_exp"], "fieldMap": { @@ -138,7 +138,7 @@ "source": "cm_sql_servers", "displayField": "hostname", "cache": ["server_id"], - "addChangelog": false, + "addChangelog": true, "fieldMap": { "dbport": "port" } @@ -146,7 +146,7 @@ "match_servers": { "source": "cm_match_servers", "displayField": "username", - "addChangelog": false, + "addChangelog": true, "booleans": [ "ssl_verify_peer", "ssl_verify_host" @@ -162,7 +162,7 @@ "match_server_attributes": { "source": "cm_match_server_attributes", "displayField": "attribute", - "addChangelog": false, + "addChangelog": true, "booleans": [ "required" ], diff --git a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php index f6a049923..24a5ecaae 100644 --- a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php +++ b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php @@ -80,6 +80,8 @@ class TransmogrifyCommand extends BaseCommand { protected ?Arguments $args = null; protected ?ConsoleIo $io = null; + protected ?CommandLinePrinter $cmdPrinter = null; + /** @var string Absolute path to plugin root directory */ private string $pluginRoot; @@ -189,8 +191,6 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'boolean' => true ]); - - $parser->setEpilog(__d('command', 'tm.epilog')); return $parser; @@ -210,6 +210,12 @@ public function execute(Arguments $args, ConsoleIo $io): int $this->args = $args; $this->io = $io; + // Info is forced to be white + $io->setStyle('info', ['text' => '0;39']); + + // Now that BaseCommand set verbosity, construct the printer so it can detect it correctly + $this->cmdPrinter = new CommandLinePrinter($io, 'green', 50, true); + // Validate "info" option combinations and handle errors $code = $this->validateInfoOptions($io); if ($code !== null) { @@ -274,7 +280,7 @@ public function execute(Arguments $args, ConsoleIo $io): int $outboundQualifiedTableName = $this->outconn->qualifyTableName($t); $Model = TableRegistry::getTableLocator()->get($t); - $io->info(message: sprintf("Transmogrifying table %s(%s)", Inflector::classify($t), $t)); + $this->cmdPrinter->info(message: sprintf("Transmogrifying table: %s(%s)", Inflector::classify($t), $t)); /* @@ -293,7 +299,7 @@ public function execute(Arguments $args, ConsoleIo $io): int // Skip a table if already contains data if($Model->find()->count() > 0) { $outboundTableEmpty = false; - $io->warning("Table (" . $t . ") is not empty. We will not overwrite existing data."); + $this->cmdPrinter->warning("Table (" . $t . ") is not empty. We will not overwrite existing data."); } // Skip tables not in the selected subset if specified @@ -304,7 +310,7 @@ public function execute(Arguments $args, ConsoleIo $io): int && !in_array($t, $selected) ) { $skipTableTransmogrification = true; - $io->warning("Skipping Transmogrification. Table ($t) is not in the selected subset."); + $this->cmdPrinter->warning("Skipping Transmogrification. Table ($t) is not in the selected subset."); } // Mark the table as skipped if it is not empty and not selected @@ -321,9 +327,9 @@ public function execute(Arguments $args, ConsoleIo $io): int $this->outconn, $this->tables[$t]['source'], $t, - $this->io + $this->cmdPrinter )) { - $io->warning("Skipping Transmogrification. Can not properly configure the Sequence for the primary key for the Table (\"$t\""); + $this->cmdPrinter->warning("Skipping Transmogrification. Can not properly configure the Sequence for the primary key for the Table (\"$t\""); return BaseCommand::CODE_ERROR; } @@ -337,14 +343,13 @@ public function execute(Arguments $args, ConsoleIo $io): int }; // Verbose message to show the SQL query being executed - $io->verbose(sprintf('[Inbound SQL] Table=%s | %s', $t, $insql)); + $this->cmdPrinter->verbose(sprintf('[Inbound SQL] Table=%s | %s', $t, $insql)); // Fetch the inbound data. $stmt = $this->inconn->executeQuery($insql); /* * PROGRESS STARTING **/ - $progress = new CommandLinePrinter($io, 'green', 50, true); // If a custom SELECT is used, count the exact result set; otherwise count the whole table if (!empty($this->tables[$t]['sqlSelect'])) { $countSql = RawSqlQueries::buildCountFromSelect($insql); @@ -352,14 +357,14 @@ public function execute(Arguments $args, ConsoleIo $io): int } else { $count = (int)$this->inconn->fetchOne(RawSqlQueries::buildCountAll($inboundQualifiedTableName)); } - $progress->start($count); + $this->cmdPrinter->start($count); $tally = 0; $warns = 0; $err = 0; while ($row = $stmt->fetchAssociative()) { if(!empty($row[ $this->tables[$t]['displayField'] ])) { - $io->verbose("$t " . $row[ $this->tables[$t]['displayField'] ]); + $this->cmdPrinter->verbose("$t " . $row[ $this->tables[$t]['displayField'] ]); } try { @@ -388,7 +393,7 @@ public function execute(Arguments $args, ConsoleIo $io): int // Insert the transformed row into the target database if($this->cache['skipInsert'][$outboundQualifiedTableName] === false) { // Check if a parent record for this row was previously rejected; if so, skip this insert - if ($this->skipIfRejectedParent(currentTable: $t, row: $row, progress: $progress)) { + if ($this->skipIfRejectedParent(currentTable: $t, row: $row)) { continue; } @@ -406,42 +411,42 @@ public function execute(Arguments $args, ConsoleIo $io): int // not linked to a CO Person that was not migrated. $warns++; $this->cache['rejected'][$outboundQualifiedTableName][$row['id']] = $row; - $progress->warn("Skipping $t record " . $row['id'] . " due to invalid foreign key: " . $e->getMessage()); - $progress->ask('Press to continue...'); + $this->cmdPrinter->warn("Skipping $t record " . $row['id'] . " due to invalid foreign key: " . $e->getMessage()); +// $this->cmdPrinter->pause(); } catch(\InvalidArgumentException $e) { // If we can't find a value for mapping we skip the record // (ie: mapLegacyFieldNames basically requires a successful mapping) $warns++; $this->cache['rejected'][$outboundQualifiedTableName][$row['id']] = $row; - $progress->warn("Skipping $t record " . $row['id'] . ": " . $e->getMessage()); - $progress->ask('Press to continue...'); + $this->cmdPrinter->warn("Skipping $t record " . $row['id'] . ": " . $e->getMessage()); +// $this->cmdPrinter->pause(); } catch(\Exception $e) { $err++; - $progress->error("$t record " . $row['id'] . ": " . $e->getMessage()); - $progress->ask('Press to continue...'); + $this->cmdPrinter->error("$t record " . $row['id'] . ": " . $e->getMessage()); +// $this->cmdPrinter->pause(); } $tally++; if (!$this->args->getOption('quiet') && !$this->args->getOption('verbose')) { - $progress->update($tally); + $this->cmdPrinter->update($tally); } } - $progress->finish(); + $this->cmdPrinter->finish(); /** * FINISH PROGRESS */ // Output final warning and error counts for the table - $io->warning(sprintf('Warnings: %d', $warns)); - $io->error(sprintf('Errors: %d', $err)); + $this->cmdPrinter->warning(sprintf('Warnings: %d', $warns)); + $this->cmdPrinter->error(sprintf('Errors: %d', $err)); // Execute any post-processing hooks for the table if($this->cache['skipInsert'][$outboundQualifiedTableName] === false) { - $io->info('Running post-table hook for ' . $t); + $this->cmdPrinter->info('Running post-table hook for ' . $t); $this->runPostTableHook($t); } @@ -449,7 +454,7 @@ public function execute(Arguments $args, ConsoleIo $io): int if (!empty($pendingSelected) && isset($pendingSelected[$t])) { unset($pendingSelected[$t]); if (empty($pendingSelected)) { - $io->info('All selected tables have been processed. Exiting.'); + $this->cmdPrinter->info('All selected tables have been processed. Exiting.'); return BaseCommand::CODE_SUCCESS; } } @@ -458,12 +463,12 @@ public function execute(Arguments $args, ConsoleIo $io): int $tables = array_keys($this->tables); $currentIndex = array_search($t, $tables); if (isset($tables[$currentIndex + 1])) { - $io->info("Next table to process: " . $tables[$currentIndex + 1]); + $this->cmdPrinter->info("Next table to process: " . $tables[$currentIndex + 1]); } else { - $io->info("This is the last table to process."); + $this->cmdPrinter->info("This is the last table to process."); } - $io->ask('Press to continue...'); + $this->cmdPrinter->pause(); } return BaseCommand::CODE_SUCCESS; @@ -654,10 +659,9 @@ protected function tableExists(string $tableName): bool * * @param string $currentTable Logical target table name (eg, 'job_history_records') * @param array $row Row to insert - * @param \Transmogrify\Lib\Util\CommandLinePrinter $progress Progress printer for warnings * @return bool True if the row should be skipped, false otherwise */ - private function skipIfRejectedParent(string $currentTable, array $row, \Transmogrify\Lib\Util\CommandLinePrinter $progress): bool + private function skipIfRejectedParent(string $currentTable, array $row): bool { if (!isset($this->cache['rejected'])) { return false; @@ -678,7 +682,7 @@ private function skipIfRejectedParent(string $currentTable, array $row, \Transmo if ($childId !== null) { $this->cache['rejected'][$qualifiedCurrent][$childId] = $row; } - $progress->warn(sprintf( + $this->cmdPrinter->warn(sprintf( 'Skipping record %d in table %s - parent %s(%d) was rejected (self-reference)', (int)($childId ?? 0), $currentTable, @@ -711,7 +715,7 @@ private function skipIfRejectedParent(string $currentTable, array $row, \Transmo if ($childId !== null) { $this->cache['rejected'][$qualifiedCurrent][$childId] = $row; } - $progress->warn(sprintf( + $this->cmdPrinter->warn(sprintf( 'Skipping record %d in table %s - parent %s(%d) was rejected', (int)($childId ?? 0), $currentTable, diff --git a/app/plugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php b/app/plugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php index a074b9a58..c054687f3 100644 --- a/app/plugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php +++ b/app/plugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php @@ -40,53 +40,53 @@ public function __construct( * @return ConsoleOptionParser Configured parser */ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser - { - $parser->setDescription('Print a JSON template shaped like __EXAMPLE_TABLE_TEMPLATE__ for mapping a source table to a target table. Target config is used only to read the example template; no values are copied. Paths must be absolute.'); - - // Switch from positional arguments to named options (long and short) - $parser->addOption('source-path', [ - 'short' => 's', - 'help' => 'Absolute path to the SOURCE configuration file (JSON or XML).', - ]); - $parser->addOption('target-path', [ - 'short' => 't', - 'help' => 'Absolute path to the TARGET configuration file (JSON or XML).', - ]); - $parser->addOption('source-table', [ - 'short' => 'S', - 'help' => 'Table key/name in the source configuration to map from.', - ]); - $parser->addOption('target-table', [ - 'short' => 'T', - 'help' => 'Table key/name in the target configuration to map to.', - ]); - $parser->addOption('source-prefix', [ - 'short' => 'p', - 'help' => 'Prefix to prepend to the source table in the output (eg, "cm_"). Default: empty string.', - ]); - - // Association tree printer option - $parser->addOption('assoc-tree', [ - 'short' => 'A', - 'help' => 'Print an ASCII tree of associations starting from the given --source-table based on the source XML. Use with -s and -S. Ignores other options and exits.', - 'boolean' => true, - ]); - - $epilog = []; - $epilog[] = 'Examples:'; - $epilog[] = ' bin/cake transmogrify_source_to_target \\\n --source-path /path/to/registry/app/Config/Schema/schema.xml \\\n --target-path /path/to/registry/app/config/transmogrifytables.json \\\n --source-table cm_co_people \\\n --target-table people'; - $epilog[] = ''; - $epilog[] = ' bin/cake transmogrify_source_to_target -s /abs/source.xml -t /abs/target.json -S cousins -T cous -p cm_'; - $epilog[] = ''; - $epilog[] = 'Notes:'; - $epilog[] = ' - Paths must be absolute.'; - $epilog[] = ' - Files may be JSON (.json) or XML (.xml).'; - $epilog[] = ' - The output is a single JSON object similar to __EXAMPLE_TABLE_TEMPLATE__ in tables.json.'; - $epilog[] = ' - Use --source-prefix/-p to control any prefix (like cm_) applied to the source table name in the output.'; - $parser->setEpilog(implode(PHP_EOL, $epilog)); - - return parent::buildOptionParser($parser); - } + { + $parser->setDescription('Print a JSON template shaped like __EXAMPLE_TABLE_TEMPLATE__ for mapping a source table to a target table. Target config is used only to read the example template; no values are copied. Paths must be absolute.'); + + // Switch from positional arguments to named options (long and short) + $parser->addOption('source-path', [ + 'short' => 's', + 'help' => 'Absolute path to the SOURCE configuration file (JSON or XML).', + ]); + $parser->addOption('target-path', [ + 'short' => 't', + 'help' => 'Absolute path to the TARGET configuration file (JSON or XML).', + ]); + $parser->addOption('source-table', [ + 'short' => 'S', + 'help' => 'Table key/name in the source configuration to map from.', + ]); + $parser->addOption('target-table', [ + 'short' => 'T', + 'help' => 'Table key/name in the target configuration to map to.', + ]); + $parser->addOption('source-prefix', [ + 'short' => 'p', + 'help' => 'Prefix to prepend to the source table in the output (eg, "cm_"). Default: empty string.', + ]); + + // Association tree printer option + $parser->addOption('assoc-tree', [ + 'short' => 'A', + 'help' => 'Print an ASCII tree of associations starting from the given --source-table based on the source XML. Use with -s and -S. Ignores other options and exits.', + 'boolean' => true, + ]); + + $epilog = []; + $epilog[] = 'Examples:'; + $epilog[] = ' bin/cake transmogrify_source_to_target \\\n --source-path /path/to/registry/app/Config/Schema/schema.xml \\\n --target-path /path/to/registry/app/config/transmogrifytables.json \\\n --source-table cm_co_people \\\n --target-table people'; + $epilog[] = ''; + $epilog[] = ' bin/cake transmogrify_source_to_target -s /abs/source.xml -t /abs/target.json -S cousins -T cous -p cm_'; + $epilog[] = ''; + $epilog[] = 'Notes:'; + $epilog[] = ' - Paths must be absolute.'; + $epilog[] = ' - Files may be JSON (.json) or XML (.xml).'; + $epilog[] = ' - The output is a single JSON object similar to __EXAMPLE_TABLE_TEMPLATE__ in tables.json.'; + $epilog[] = ' - Use --source-prefix/-p to control any prefix (like cm_) applied to the source table name in the output.'; + $parser->setEpilog(implode(PHP_EOL, $epilog)); + + return parent::buildOptionParser($parser); + } /** * Execute the command @@ -96,205 +96,324 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption * @return int Exit code */ public function execute(Arguments $args, ConsoleIo $io): int - { - // capture prefix for helper methods - $this->currentPrefix = (string)($args->getOption('source-prefix') ?? ''); - $sourcePath = (string)($args->getOption('source-path') ?? ''); - $targetPath = (string)($args->getOption('target-path') ?? ''); - $sourceTable = (string)($args->getOption('source-table') ?? ''); - $targetTable = (string)($args->getOption('target-table') ?? ''); - $sourcePrefix = (string)($args->getOption('source-prefix') ?? ''); - $assocTree = (bool)($args->getOption('assoc-tree') ?? false); - - // If only association tree requested, we only need source path and source table - if ($assocTree) { - $missing = []; - if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } - if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } - if (!empty($missing)) { - $io->err('Missing required option(s) for --assoc-tree: ' . implode(', ', $missing)); - $io->err('Run with --help to see usage.'); - return BaseCommand::CODE_ERROR; - } - if ($sourcePath === '' || !str_starts_with($sourcePath, DIRECTORY_SEPARATOR)) { - $io->err('SOURCE path must be absolute: ' . $sourcePath); - return BaseCommand::CODE_ERROR; - } - if (!is_readable($sourcePath)) { - $io->err('SOURCE file not readable: ' . $sourcePath); - return BaseCommand::CODE_ERROR; - } + { + // capture prefix for helper methods + $this->currentPrefix = (string)($args->getOption('source-prefix') ?? ''); + $sourcePath = (string)($args->getOption('source-path') ?? ''); + $targetPath = (string)($args->getOption('target-path') ?? ''); + $sourceTable = (string)($args->getOption('source-table') ?? ''); + $targetTable = (string)($args->getOption('target-table') ?? ''); + $sourcePrefix = (string)($args->getOption('source-prefix') ?? ''); + $assocTree = (bool)($args->getOption('assoc-tree') ?? false); + + // If only association tree requested, we only need source path and source table + if ($assocTree) { + $missing = []; + if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } + if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } + if (!empty($missing)) { + $io->err('Missing required option(s) for --assoc-tree: ' . implode(', ', $missing)); + $io->err('Run with --help to see usage.'); + return BaseCommand::CODE_ERROR; + } + if ($sourcePath === '' || !str_starts_with($sourcePath, DIRECTORY_SEPARATOR)) { + $io->err('SOURCE path must be absolute: ' . $sourcePath); + return BaseCommand::CODE_ERROR; + } + if (!is_readable($sourcePath)) { + $io->err('SOURCE file not readable: ' . $sourcePath); + return BaseCommand::CODE_ERROR; + } + + try { + $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); + } catch (\Throwable $e) { + $io->err('Failed to load source configuration: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + $this->printAssociationTreeFromSchema($srcCfgRaw, $sourceTable, $io); + return BaseCommand::CODE_SUCCESS; + } - try { - $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); - } catch (\Throwable $e) { - $io->err('Failed to load source configuration: ' . $e->getMessage()); - return BaseCommand::CODE_ERROR; - } - $this->printAssociationTreeFromSchema($srcCfgRaw, $sourceTable, $io); - return BaseCommand::CODE_SUCCESS; - } + // Validate required named options are present + $missing = []; + if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } + if ($targetPath === '') { $missing[] = '--target-path (-t)'; } + if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } + if ($targetTable === '') { $missing[] = '--target-table (-T)'; } + if (!empty($missing)) { + $io->err('Missing required option(s): ' . implode(', ', $missing)); + $io->err('Run with --help to see usage.'); + return BaseCommand::CODE_ERROR; + } - // Validate required named options are present - $missing = []; - if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } - if ($targetPath === '') { $missing[] = '--target-path (-t)'; } - if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } - if ($targetTable === '') { $missing[] = '--target-table (-T)'; } - if (!empty($missing)) { - $io->err('Missing required option(s): ' . implode(', ', $missing)); - $io->err('Run with --help to see usage.'); - return BaseCommand::CODE_ERROR; - } + // Validate absolute paths + foreach (['source' => $sourcePath, 'target' => $targetPath] as $label => $path) { + if ($path === '' || !str_starts_with($path, DIRECTORY_SEPARATOR)) { + $io->err(strtoupper($label) . ' path must be absolute: ' . $path); + return BaseCommand::CODE_ERROR; + } + if (!is_readable($path)) { + $io->err(strtoupper($label) . ' file not readable: ' . $path); + return BaseCommand::CODE_ERROR; + } + } - // Validate absolute paths - foreach (['source' => $sourcePath, 'target' => $targetPath] as $label => $path) { - if ($path === '' || !str_starts_with($path, DIRECTORY_SEPARATOR)) { - $io->err(strtoupper($label) . ' path must be absolute: ' . $path); - return BaseCommand::CODE_ERROR; - } - if (!is_readable($path)) { - $io->err(strtoupper($label) . ' file not readable: ' . $path); - return BaseCommand::CODE_ERROR; - } - } + try { + // Load target config to read __EXAMPLE_TABLE_TEMPLATE__ shape + $tgtCfg = $this->configLoaderService->loadGeneric($targetPath); + // Load source config to extract old schema (eg, from XML) + $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); + } catch (\Throwable $e) { + $io->err('Failed to load configuration: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } - try { - // Load target config to read __EXAMPLE_TABLE_TEMPLATE__ shape - $tgtCfg = $this->configLoaderService->loadGeneric($targetPath); - // Load source config to extract old schema (eg, from XML) - $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); - } catch (\Throwable $e) { - $io->err('Failed to load configuration: ' . $e->getMessage()); - return BaseCommand::CODE_ERROR; - } + // Load the migration config (tables.json) to derive generic co_* FK renames + $tablesJsonPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'schema' . DIRECTORY_SEPARATOR . 'tables.json'; + $tablesCfg = []; + if (is_readable($tablesJsonPath)) { + try { + $tablesCfg = $this->configLoaderService->load($tablesJsonPath); + } catch (\Throwable $e) { + $io->warning('Could not load migration config tables.json: ' . $e->getMessage()); + } + } - // Normalize possible schema.xml structures into transmogrify-like configs for source - $srcCfg = $this->normalizeConfig(is_array($srcCfgRaw) ? $srcCfgRaw : []); - - // Extract example template (shape) from target config without filtering out __ keys first - $example = is_array($tgtCfg) && array_key_exists('__EXAMPLE_TABLE_TEMPLATE__', $tgtCfg) - ? (array)$tgtCfg['__EXAMPLE_TABLE_TEMPLATE__'] - : []; - - // Determine the output keys based on the example (ignoring any __* documentation keys) - $exampleKeys = array_filter(array_keys($example), static function($k) { - return !is_string($k) || !str_starts_with($k, '__'); - }); - - // Determine source fields from old schema configuration (if available) - $sourceFields = []; - [$srcKey, $srcTableCfg] = $this->findTableConfig($srcCfg, $sourceTable); - if (is_array($srcTableCfg)) { - if (!empty($srcTableCfg['fieldMap']) && is_array($srcTableCfg['fieldMap'])) { - $sourceFields = array_keys($srcTableCfg['fieldMap']); - } elseif (!empty($srcTableCfg['fields']) && is_array($srcTableCfg['fields'])) { - $sourceFields = array_values($srcTableCfg['fields']); - } + // Normalize possible schema.xml structures into transmogrify-like configs for source + $srcCfg = $this->normalizeConfig(is_array($srcCfgRaw) ? $srcCfgRaw : []); + + // Extract example template (shape) from target config without filtering out __ keys first + $example = is_array($tgtCfg) && array_key_exists('__EXAMPLE_TABLE_TEMPLATE__', $tgtCfg) + ? (array)$tgtCfg['__EXAMPLE_TABLE_TEMPLATE__'] + : []; + + // Determine the output keys based on the example (ignoring any __* documentation keys) + $exampleKeys = array_filter(array_keys($example), static function($k) { + return !is_string($k) || !str_starts_with($k, '__'); + }); + + // Determine source fields from old schema configuration (if available) + $sourceFields = []; + [$srcKey, $srcTableCfg] = $this->findTableConfig($srcCfg, $sourceTable); + if (is_array($srcTableCfg)) { + if (!empty($srcTableCfg['fieldMap']) && is_array($srcTableCfg['fieldMap'])) { + $sourceFields = array_keys($srcTableCfg['fieldMap']); + } elseif (!empty($srcTableCfg['fields']) && is_array($srcTableCfg['fields'])) { + $sourceFields = array_values($srcTableCfg['fields']); + } + } + $sourceFieldSet = array_flip($sourceFields); + + // Build legacy FK pattern map (eg, *_co_person_id -> *_person_id, *_co_message_template_id -> *_message_template_id) + $legacyFkPatterns = $this->buildLegacyFkPatterns($tablesCfg, (string)$sourcePrefix); + + // Inspect default database to find target table columns + try { + $conn = DBALConnection::factory($io); + $columns = []; + $candidate = $targetTable; + $alts = [ $candidate ]; + // toggle provided prefix to try alternative table naming + $prefix = (string)($sourcePrefix ?? ''); + if ($prefix !== '') { + $alts[] = str_starts_with($candidate, $prefix) ? substr($candidate, strlen($prefix)) : ($prefix . $candidate); + } + $foundTable = null; + foreach (array_unique($alts) as $tbl) { + if ($conn->isMySQL()) { + $db = $conn->fetchOne('SELECT DATABASE()'); + $cols = $conn->fetchAllAssociative('SELECT column_name, data_type, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', [$db, $tbl]); + if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } + } else { + // PostgreSQL: search in current schema(s) + $cols = $conn->fetchAllAssociative("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position", [$tbl]); + if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } } - $sourceFieldSet = array_flip($sourceFields); - - // Inspect default database to find target table columns - try { - $conn = DBALConnection::factory($io); - $columns = []; - $candidate = $targetTable; - $alts = [ $candidate ]; - // toggle provided prefix to try alternative table naming - $prefix = (string)($sourcePrefix ?? ''); - if ($prefix !== '') { - $alts[] = str_starts_with($candidate, $prefix) ? substr($candidate, strlen($prefix)) : ($prefix . $candidate); - } - $foundTable = null; - foreach (array_unique($alts) as $tbl) { - if ($conn->isMySQL()) { - $db = $conn->fetchOne('SELECT DATABASE()'); - $cols = $conn->fetchAllAssociative('SELECT column_name, data_type, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', [$db, $tbl]); - if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } - } else { - // PostgreSQL: search in current schema(s) - $cols = $conn->fetchAllAssociative("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position", [$tbl]); - if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } - } - } - if ($foundTable === null) { - $io->err('Target table not found in default database: ' . $targetTable); - return BaseCommand::CODE_ERROR; - } - // Build fieldMap using DB columns and source fields set - $fieldMap = []; - $booleanCols = []; - foreach ($columns as $col) { - $colName = $col['column_name']; - $fieldMap[$colName] = array_key_exists($colName, $sourceFieldSet) ? $colName : null; - // Detect booleans by database type - if ($conn->isMySQL()) { - $dt = strtolower((string)($col['data_type'] ?? '')); - $ct = strtolower((string)($col['column_type'] ?? '')); - $isBool = ($dt === 'tinyint' && str_starts_with($ct, 'tinyint(1)')) || ($dt === 'bit' && str_starts_with($ct, 'bit(1)')) || ($dt === 'boolean'); - if ($isBool) { $booleanCols[] = $colName; } - } else { - $dt = strtolower((string)($col['data_type'] ?? '')); - if ($dt === 'boolean' || $dt === 'bool') { $booleanCols[] = $colName; } - } + } + if ($foundTable === null) { + $io->err('Target table not found in default database: ' . $targetTable); + return BaseCommand::CODE_ERROR; + } + + // Build target column name lists + $targetCols = array_map(static fn($c) => (string)$c['column_name'], $columns); + $targetColSetAll = array_flip($targetCols); + + $singular = \Cake\Utility\Inflector::singularize($targetTable); + $metaCols = [ + $singular . '_id', + 'revision', + 'deleted', + 'actor_identifier', + 'created', + 'modified', + ]; + + $targetColSet = $targetColSetAll; + foreach ($metaCols as $mc) { + unset($targetColSet[$mc]); + } + + // Determine if target natively has changelog fields (controls addChangelog) + $targetHasChangelog = ( + array_key_exists($singular . '_id', $targetColSetAll) + && array_key_exists('revision', $targetColSetAll) + && array_key_exists('deleted', $targetColSetAll) + && array_key_exists('actor_identifier', $targetColSetAll) + ); + + // Determine if source has changelog (v4 pattern: revision, deleted, actor_identifier, and legacy self-FK) + $legacySelfFkExact = 'co_' . $singular . '_id'; + $legacySelfFkSuffix = '_co_' . $singular . '_id'; + $fieldsLower = array_map('strtolower', $sourceFields); + $sourceFieldSet = array_flip($fieldsLower); + $sourceHasChangelog = ( + isset($sourceFieldSet['revision']) + && isset($sourceFieldSet['deleted']) + && isset($sourceFieldSet['actor_identifier']) + && ( + in_array($legacySelfFkExact, $fieldsLower, true) + || (bool)array_filter($fieldsLower, fn($s) => str_ends_with($s, $legacySelfFkSuffix)) + ) + ); + + // Detect booleans by database type (for non-metadata only) + $booleanCols = []; + foreach ($columns as $col) { + $colName = (string)$col['column_name']; + if (in_array($colName, $metaCols, true)) { continue; } + if ($conn->isMySQL()) { + $dt = strtolower((string)($col['data_type'] ?? '')); + $ct = strtolower((string)($col['column_type'] ?? '')); + $isBool = ($dt === 'tinyint' && str_starts_with($ct, 'tinyint(1)')) || ($dt === 'bit' && str_starts_with($ct, 'bit(1)')) || ($dt === 'boolean'); + if ($isBool) { $booleanCols[] = $colName; } + } else { + $dt = strtolower((string)($col['data_type'] ?? '')); + if ($dt === 'boolean' || $dt === 'bool') { $booleanCols[] = $colName; } + } + } + + // Build fieldMap as source-left => target-right (include null when unknown) + $fieldMap = []; + $mappedTargets = []; + $sourceMeta = ['revision','deleted','actor_identifier','created','modified']; + foreach ($sourceFields as $scol) { + if (in_array($scol, $sourceMeta, true)) { continue; } + + // 1) exact same-name target (non-metadata set) + $tcol = array_key_exists($scol, $targetColSet) ? $scol : null; + + // 2) normalization-based mapping (generic co_* fk rename via patterns + specific one-offs) + if ($tcol === null) { + $candidate = $this->normalizeTargetNameForSourceColumn($scol, $legacyFkPatterns); + if ($candidate !== null) { + $isMetaSelfFk = ($candidate === $singular . '_id'); + // accept candidate if it's a normal column, or it's the allowed self-FK + if (array_key_exists($candidate, $targetColSet) + || ($isMetaSelfFk && array_key_exists($candidate, $targetColSetAll))) { + $tcol = $candidate; } - } catch (\Throwable $e) { - $io->err('Failed to inspect database schema: ' . $e->getMessage()); - return BaseCommand::CODE_ERROR; + } } - // Build a template strictly using the example shape. The target config is only used for the example; no values are copied. - // Default values: - // - source: from the provided --source-table option - // - displayField/addChangelog/sqlSelect/pre*/post*: null - // - booleans/cache: [] - // - fieldMap: constructed from DB columns and source schema - $defaults = [ - 'source' => $sourcePrefix . $sourceTable, - 'displayField' => $this->determineDisplayField($sourceFields), - 'addChangelog' => $this->shouldAddChangelog($srcTableCfg['source'] ?? $sourceTable, $sourceFields), - 'booleans' => $booleanCols, - 'cache' => [], - 'sqlSelect' => null, - 'preTable' => null, - 'postTable' => null, - 'preRow' => null, - 'postRow' => null, - 'fieldMap' => $fieldMap ?: new \stdClass(), - ]; - - // If example keys were found, limit output to those keys (plus ensure 'source' exists). - if (!empty($exampleKeys)) { - $template = []; - foreach ($exampleKeys as $k) { - if ($k === 'source') { - $template[$k] = $sourcePrefix . $sourceTable; - continue; - } - $template[$k] = $defaults[$k] ?? null; - } - // Ensure mandatory keys exist even if not in example - foreach (['source','fieldMap'] as $must) { - if (!array_key_exists($must, $template)) { - $template[$must] = $defaults[$must]; - } - } - } else { - // Fallback to standard template keys - $template = $defaults; + $fieldMap[$scol] = $tcol; + if ($tcol !== null) { $mappedTargets[$tcol] = true; } + } + + // Collect target-only columns (user can decide later). + $targetUnmapped = []; + foreach (array_keys($targetColSetAll) as $tcol) { + // skip global metadata from the hint list + if (in_array($tcol, $metaCols, true)) { continue; } + if (!isset($mappedTargets[$tcol])) { + $targetUnmapped[] = $tcol; } + } + } catch (\Throwable $e) { + $io->err('Failed to inspect database schema: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } - // Ensure JSON encoding preserves empty objects properly for fieldMap - $json = json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($json === false) { - $io->err('Failed to encode output JSON'); - return BaseCommand::CODE_ERROR; + + // Build a template strictly using the example shape. The target config is only used for the example; no values are copied. + $defaults = [ + 'source' => $sourcePrefix . $sourceTable, + 'displayField' => $this->determineDisplayField($sourceFields), + // Note: we will attach/omit addChangelog below based on source/target + //'addChangelog' => '??', + 'booleans' => $booleanCols, + 'cache' => [], + 'sqlSelect' => null, + 'preTable' => null, + 'postTable' => null, + 'preRow' => null, + 'postRow' => null, + 'fieldMap' => $fieldMap ?: new \stdClass(), + ]; + + // If example keys were found, limit output to those keys (plus ensure 'source' exists). + if (!empty($exampleKeys)) { + $template = []; + foreach ($exampleKeys as $k) { + if ($k === 'source') { + $template[$k] = $sourcePrefix . $sourceTable; + continue; + } + $template[$k] = $defaults[$k] ?? null; + } + foreach (['source','fieldMap'] as $must) { + if (!array_key_exists($must, $template)) { + $template[$must] = $defaults[$must]; } + } + } else { + $template = $defaults; + } + + // Decide addChangelog per rules: + // - both have changelog: omit configuration + // - target has changelog (source doesn't): true + // - target doesn't have changelog (source does): false + $addChangelogOpt = null; + if ($targetHasChangelog && $sourceHasChangelog) { + $addChangelogOpt = null; // omit + } elseif ($targetHasChangelog && !$sourceHasChangelog) { + $addChangelogOpt = true; + } elseif (!$targetHasChangelog && $sourceHasChangelog) { + $addChangelogOpt = false; + } else { + $addChangelogOpt = null; // neither has: omit + } + + // Apply addChangelog decision + if (array_key_exists('addChangelog', $template)) { + unset($template['addChangelog']); + } + if ($addChangelogOpt !== null) { + $template['addChangelog'] = $addChangelogOpt; + } + - $io->out($json); - return BaseCommand::CODE_SUCCESS; + // Attach target-only hints for the user + if (!empty($targetUnmapped)) { + $template['targetUnmapped'] = $targetUnmapped; } + // Wrap result under the target-table key for direct insertion into tables.json + $wrapped = [ $targetTable => $template ]; + + // Ensure JSON encoding preserves empty objects properly for fieldMap + $json = json_encode($wrapped, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $io->err('Failed to encode output JSON'); + return BaseCommand::CODE_ERROR; + } + + $io->out($json); + return BaseCommand::CODE_SUCCESS; + } + /** * Filter out documentation keys (starting with __) from configuration array * @@ -302,272 +421,351 @@ public function execute(Arguments $args, ConsoleIo $io): int * @return array Filtered configuration without documentation keys */ private function filterDocKeys(array $cfg): array - { - return array_filter($cfg, static function ($value, $key) { - return !(is_string($key) && str_starts_with($key, '__')); - }, ARRAY_FILTER_USE_BOTH); - } - - /** - * Decide if addChangelog should be true based on source configuration fields. - * Requirements: revision, deleted, actor_identifier, and self-referencing FK (eg, cous -> cou_id). - * - * @param string $tableName Name/key of the source table (may be prefixed like cm_) - * @param array $sourceFields List of source column names - */ - private function shouldAddChangelog(string $tableName, array $sourceFields): bool - { - if (empty($sourceFields)) { - return false; - } - $fields = array_map('strtolower', $sourceFields); - $fieldSet = array_flip($fields); + { + return array_filter($cfg, static function ($value, $key) { + return !(is_string($key) && str_starts_with($key, '__')); + }, ARRAY_FILTER_USE_BOTH); + } - // Required fixed columns - foreach (['revision','deleted','actor_identifier'] as $req) { - if (!array_key_exists($req, $fieldSet)) { - return false; - } - } - // Determine expected self FK - $name = strtolower($tableName); - $prefix = (string)($this->currentPrefix ?? ''); - if ($prefix !== '' && str_starts_with($name, $prefix)) { - $name = substr($name, strlen($prefix)); - } - // If table name is schema-qualified (eg, public.table), take part after dot - $dot = strrpos($name, '.'); - if ($dot !== false) { - $name = substr($name, $dot + 1); - } - $base = $name; - if (str_ends_with($base, 's')) { - $base = substr($base, 0, -1); - } - $selfFk = $base . '_id'; - - return array_key_exists($selfFk, $fieldSet); - } - - /** - * Normalize a loaded configuration array. - * - If it's a schema-like array (has 'table' nodes), convert to a transmogrify-like map - * keyed by table name with at least 'source' and 'fieldMap'. - */ - private function normalizeConfig(array $cfg): array - { - // Detect schema root - $tablesNode = null; - if (array_key_exists('table', $cfg)) { - $tablesNode = $cfg['table']; - } elseif (isset($cfg['schema']) && is_array($cfg['schema']) && array_key_exists('table', $cfg['schema'])) { - $tablesNode = $cfg['schema']['table']; - } - if ($tablesNode === null) { - // Already config-like - return $cfg; - } - // Ensure list - if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { - // Single table object -> wrap - $tables = [$tablesNode]; - } else { - $tables = $tablesNode; - } - $out = []; - foreach ($tables as $t) { - if (!is_array($t)) { continue; } - // Name is typically under '@attributes' => ['name' => ...] - $name = null; - if (isset($t['@attributes']['name'])) { - $name = (string)$t['@attributes']['name']; - } elseif (isset($t['name'])) { - // Fallback if converter placed it differently - $name = is_array($t['name']) && isset($t['name']['@attributes']) ? (string)($t['name']['@attributes']['value'] ?? '') : (string)$t['name']; - } - if ($name === null || $name === '') { continue; } - - // Build simple 1:1 field map using elements, if present - $fieldMap = new \stdClass(); - if (isset($t['field'])) { - $fieldsNode = $t['field']; - // Normalize to list - if (!is_array($fieldsNode) || (is_array($fieldsNode) && array_keys($fieldsNode) !== range(0, count($fieldsNode)-1))) { - $fields = [$fieldsNode]; - } else { - $fields = $fieldsNode; - } - $map = []; - foreach ($fields as $f) { - if (!is_array($f)) { continue; } - $fname = $f['@attributes']['name'] ?? null; - if ($fname) { - $map[$fname] = $fname; - } - } - if (!empty($map)) { - $fieldMap = $map; - } - } + /** + * Decide if addChangelog should be true based on source configuration fields. + * Requirements: revision, deleted, actor_identifier, and self-referencing FK (eg, cous -> cou_id). + * + * @param string $tableName Name/key of the source table (may be prefixed like cm_) + * @param array $sourceFields List of source column names + */ + private function shouldAddChangelog(string $tableName, array $sourceFields): bool + { + if (empty($sourceFields)) { + return false; + } + $fields = array_map('strtolower', $sourceFields); + $fieldSet = array_flip($fields); + + // Required fixed columns + foreach (['revision','deleted','actor_identifier'] as $req) { + if (!array_key_exists($req, $fieldSet)) { + return false; + } + } + // Determine expected self FK + $name = strtolower($tableName); + $prefix = (string)($this->currentPrefix ?? ''); + if ($prefix !== '' && str_starts_with($name, $prefix)) { + $name = substr($name, strlen($prefix)); + } + // If table name is schema-qualified (eg, public.table), take part after dot + $dot = strrpos($name, '.'); + if ($dot !== false) { + $name = substr($name, $dot + 1); + } + $base = $name; + if (str_ends_with($base, 's')) { + $base = substr($base, 0, -1); + } + $selfFk = $base . '_id'; - $out[$name] = [ - 'source' => $name, - 'displayField' => $this->determineDisplayField(is_array($fieldMap) ? array_keys($fieldMap) : []), - 'fieldMap' => $fieldMap - ]; - } - return $out; - } - - /** - * Locate a table configuration by key or by matching the 'source' attribute. - * Also tries matching names with/without a leading provided prefix. - * - * @param array $cfg - * @param string $nameOrSource Either the config key or the source table name - * @return array{0:string|null,1:array|null} [key, config] - */ - private function findTableConfig(array $cfg, string $nameOrSource): array - { - if (array_key_exists($nameOrSource, $cfg) && is_array($cfg[$nameOrSource])) { - return [$nameOrSource, $cfg[$nameOrSource]]; - } - $prefix = (string)($this->currentPrefix ?? ''); - $alt = $nameOrSource; - if ($prefix !== '') { - $alt = str_starts_with($nameOrSource, $prefix) ? substr($nameOrSource, strlen($prefix)) : ($prefix . $nameOrSource); - } - if (array_key_exists($alt, $cfg) && is_array($cfg[$alt])) { - return [$alt, $cfg[$alt]]; - } - foreach ($cfg as $key => $val) { - if (!is_array($val)) { continue; } - $src = $val['source'] ?? null; - if ($src === $nameOrSource || $src === $alt) { - return [$key, $val]; - } - // Also allow key matching to bare table name - if ($key === $nameOrSource || $key === $alt) { - return [$key, $val]; - } + return array_key_exists($selfFk, $fieldSet); + } + + /** + * Normalize a loaded configuration array. + * - If it's a schema-like array (has 'table' nodes), convert to a transmogrify-like map + * keyed by table name with at least 'source' and 'fieldMap'. + */ + private function normalizeConfig(array $cfg): array + { + // Detect schema root + $tablesNode = null; + if (array_key_exists('table', $cfg)) { + $tablesNode = $cfg['table']; + } elseif (isset($cfg['schema']) && is_array($cfg['schema']) && array_key_exists('table', $cfg['schema'])) { + $tablesNode = $cfg['schema']['table']; + } + if ($tablesNode === null) { + // Already config-like + return $cfg; + } + // Ensure list + if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { + // Single table object -> wrap + $tables = [$tablesNode]; + } else { + $tables = $tablesNode; + } + $out = []; + foreach ($tables as $t) { + if (!is_array($t)) { continue; } + // Name is typically under '@attributes' => ['name' => ...] + $name = null; + if (isset($t['@attributes']['name'])) { + $name = (string)$t['@attributes']['name']; + } elseif (isset($t['name'])) { + // Fallback if converter placed it differently + $name = is_array($t['name']) && isset($t['name']['@attributes']) ? (string)($t['name']['@attributes']['value'] ?? '') : (string)$t['name']; + } + if ($name === null || $name === '') { continue; } + + // Build simple 1:1 field map using elements, if present + $fieldMap = new \stdClass(); + if (isset($t['field'])) { + $fieldsNode = $t['field']; + // Normalize to list + if (!is_array($fieldsNode) || (is_array($fieldsNode) && array_keys($fieldsNode) !== range(0, count($fieldsNode)-1))) { + $fields = [$fieldsNode]; + } else { + $fields = $fieldsNode; } - return [null, null]; - } - - /** - * Determine displayField from source table fields using provided order: - * name, display_name, username, authenticated_identifier, description, id. - * Returns null if none are present. - */ - private function determineDisplayField(array $sourceFields): ?string - { - if (empty($sourceFields)) { - return null; + $map = []; + foreach ($fields as $f) { + if (!is_array($f)) { continue; } + $fname = $f['@attributes']['name'] ?? null; + if ($fname) { + $map[$fname] = $fname; + } } - $fields = array_map('strtolower', $sourceFields); - $set = array_flip($fields); - foreach (['name','display_name','username','authenticated_identifier','description','id'] as $candidate) { - if (array_key_exists($candidate, $set)) { - return $candidate; - } + if (!empty($map)) { + $fieldMap = $map; } - return null; - } - - /** - * Print an ASCII association tree for a given starting table using the parsed XML schema array. - * Supports common schema.xml structures with elements containing @attributes["foreignTable"] - * and nested elements. - */ - private function printAssociationTreeFromSchema(array $schemaArr, string $startTable, ConsoleIo $io): void - { - // Build associative array tree from schema.xml-like array - // We expect tables under ['schema']['table'] or directly under ['table'] - $tablesNode = $schemaArr['schema']['table'] ?? ($schemaArr['table'] ?? []); - // Normalize to list of tables - if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { - $tables = [$tablesNode]; + } + + $out[$name] = [ + 'source' => $name, + 'displayField' => $this->determineDisplayField(is_array($fieldMap) ? array_keys($fieldMap) : []), + 'fieldMap' => $fieldMap + ]; + } + return $out; + } + + /** + * Locate a table configuration by key or by matching the 'source' attribute. + * Also tries matching names with/without a leading provided prefix. + * + * @param array $cfg + * @param string $nameOrSource Either the config key or the source table name + * @return array{0:string|null,1:array|null} [key, config] + */ + private function findTableConfig(array $cfg, string $nameOrSource): array + { + if (array_key_exists($nameOrSource, $cfg) && is_array($cfg[$nameOrSource])) { + return [$nameOrSource, $cfg[$nameOrSource]]; + } + $prefix = (string)($this->currentPrefix ?? ''); + $alt = $nameOrSource; + if ($prefix !== '') { + $alt = str_starts_with($nameOrSource, $prefix) ? substr($nameOrSource, strlen($prefix)) : ($prefix . $nameOrSource); + } + if (array_key_exists($alt, $cfg) && is_array($cfg[$alt])) { + return [$alt, $cfg[$alt]]; + } + foreach ($cfg as $key => $val) { + if (!is_array($val)) { continue; } + $src = $val['source'] ?? null; + if ($src === $nameOrSource || $src === $alt) { + return [$key, $val]; + } + // Also allow key matching to bare table name + if ($key === $nameOrSource || $key === $alt) { + return [$key, $val]; + } + } + return [null, null]; + } + + /** + * Determine displayField from source table fields using provided order: + * name, display_name, username, authenticated_identifier, description, id. + * Returns null if none are present. + */ + private function determineDisplayField(array $sourceFields): ?string + { + if (empty($sourceFields)) { + return null; + } + $fields = array_map('strtolower', $sourceFields); + $set = array_flip($fields); + foreach (['name','display_name','username','authenticated_identifier','description','id'] as $candidate) { + if (array_key_exists($candidate, $set)) { + return $candidate; + } + } + return null; + } + + /** + * Print an ASCII association tree for a given starting table using the parsed XML schema array. + * Supports common schema.xml structures with elements containing @attributes["foreignTable"] + * and nested elements. + */ + private function printAssociationTreeFromSchema(array $schemaArr, string $startTable, ConsoleIo $io): void + { + // Build associative array tree from schema.xml-like array + // We expect tables under ['schema']['table'] or directly under ['table'] + $tablesNode = $schemaArr['schema']['table'] ?? ($schemaArr['table'] ?? []); + // Normalize to list of tables + if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { + $tables = [$tablesNode]; + } else { + $tables = $tablesNode; + } + // Build map: tableName => [ children tables ... ] based on column @attributes['constraint'] value 'REFERENCE table(column)' + $children = []; + foreach ($tables as $t) { + if (!is_array($t)) { continue; } + $tbl = $t['@attributes']['name'] ?? ($t['name'] ?? null); + if ($tbl === null) { continue; } + $tbl = (string)$tbl; + if (!isset($children[$tbl])) { $children[$tbl] = []; } + // inspect columns/field definitions; schema.xml may use or + $colsNode = $t['column'] ?? ($t['field'] ?? []); + $cols = []; + if ($colsNode !== [] && $colsNode !== null) { + if (!is_array($colsNode) || (is_array($colsNode) && array_keys($colsNode) !== range(0, count($colsNode)-1))) { + $cols = [$colsNode]; } else { - $tables = $tablesNode; - } - // Build map: tableName => [ children tables ... ] based on column @attributes['constraint'] value 'REFERENCE table(column)' - $children = []; - foreach ($tables as $t) { - if (!is_array($t)) { continue; } - $tbl = $t['@attributes']['name'] ?? ($t['name'] ?? null); - if ($tbl === null) { continue; } - $tbl = (string)$tbl; - if (!isset($children[$tbl])) { $children[$tbl] = []; } - // inspect columns/field definitions; schema.xml may use or - $colsNode = $t['column'] ?? ($t['field'] ?? []); - $cols = []; - if ($colsNode !== [] && $colsNode !== null) { - if (!is_array($colsNode) || (is_array($colsNode) && array_keys($colsNode) !== range(0, count($colsNode)-1))) { - $cols = [$colsNode]; - } else { - $cols = $colsNode; - } - } - foreach ($cols as $idx => $col) { - if (!is_array($col)) { continue; } - $constraint = $col['constraint'] ?? null; - if (!is_string($constraint)) { continue; } - // Expect format: 'REFERENCE table(column)' - if (preg_match('/^REFERENCES\s+([A-Za-z0-9_\.]+)\s*\(([^)]+)\)/', $constraint, $m)) { - $refTable = $m[1]; - // Remove prefix if it was provided and matches - if ($this->currentPrefix !== '' && str_starts_with($refTable, $this->currentPrefix)) { - $refTable = substr($refTable, strlen($this->currentPrefix)); - } - - // current table depends on refTable -> edge from current to parent - // For tree of ancestors starting from $startTable, we build parent map - // but for associative array tree representation, we'll build nested children where parent has child - // i.e., parent -> [ child1, child2 ] based on references found in child - if (!isset($children[$tbl])) { $children[$tbl] = []; } - if (!in_array($refTable, $children[$tbl], true)) { - $children[$tbl][] = $refTable; - } - } - } - } - // Build recursive associative array tree starting at $startTable - $visited = []; - $buildTree = function(string $node) use (&$buildTree, &$children, &$visited) { - if (isset($visited[$node])) { - // cycle: denote specially - return ['__cycle__' => $node]; - } - $visited[$node] = true; - $kids = $children[$node] ?? []; - $tree = []; - foreach ($kids as $k) { - $tree[$k] = $buildTree($k); - } - return $tree; - }; - $tree = [ $startTable => $buildTree($startTable) ]; - // Output the array via Console IO as JSON for readability - $io->out(json_encode($tree, JSON_PRETTY_PRINT)); - } - - private function printAncestorTree(string $node, array $parents, array &$visited, ConsoleIo $io, string $prefix): void - { - if (isset($visited[$node])) { - $io->out($prefix . '↺ ' . $node . ' (cycle)'); - return; + $cols = $colsNode; } - $visited[$node] = true; - $edges = $parents[$node] ?? []; - $count = count($edges); - foreach ($edges as $idx => $edge) { - $isLast = ($idx === $count - 1); - $branch = $isLast ? '└── ' : '├── '; - $nextPrefix = $prefix . ($isLast ? ' ' : '│ '); - $label = $edge['to'] ?? '?'; // parent table - $via = $edge['via'] ?? ''; - $io->out($prefix . $branch . $label . ($via !== '' ? (' [' . $via . ']') : '')); - $this->printAncestorTree((string)$label, $parents, $visited, $io, $nextPrefix); + } + foreach ($cols as $idx => $col) { + if (!is_array($col)) { continue; } + $constraint = $col['constraint'] ?? null; + if (!is_string($constraint)) { continue; } + // Expect format: 'REFERENCE table(column)' + if (preg_match('/^REFERENCES\s+([A-Za-z0-9_\.]+)\s*\(([^)]+)\)/', $constraint, $m)) { + $refTable = $m[1]; + // Remove prefix if it was provided and matches + if ($this->currentPrefix !== '' && str_starts_with($refTable, $this->currentPrefix)) { + $refTable = substr($refTable, strlen($this->currentPrefix)); + } + + // current table depends on refTable -> edge from current to parent + // For tree of ancestors starting from $startTable, we build parent map + // but for associative array tree representation, we'll build nested children where parent has child + // i.e., parent -> [ child1, child2 ] based on references found in child + if (!isset($children[$tbl])) { $children[$tbl] = []; } + if (!in_array($refTable, $children[$tbl], true)) { + $children[$tbl][] = $refTable; + } } + } + } + // Build recursive associative array tree starting at $startTable + $visited = []; + $buildTree = function(string $node) use (&$buildTree, &$children, &$visited) { + if (isset($visited[$node])) { + // cycle: denote specially + return ['__cycle__' => $node]; + } + $visited[$node] = true; + $kids = $children[$node] ?? []; + $tree = []; + foreach ($kids as $k) { + $tree[$k] = $buildTree($k); + } + return $tree; + }; + $tree = [ $startTable => $buildTree($startTable) ]; + // Output the array via Console IO as JSON for readability + $io->out(json_encode($tree, JSON_PRETTY_PRINT)); + } + + /** + * Build legacy co_* foreign key rename patterns based on migration config. + * Returns an array of [suffixFrom => suffixTo], eg: + * '_co_person_id' => '_person_id' + * '_co_group_id' => '_group_id' + * '_co_message_template_id' => '_message_template_id' + * + * The patterns are applied as “ends-with” replacements to source column names. + */ + private function buildLegacyFkPatterns(array $tablesCfg, string $sourcePrefix): array + { + if (!is_array($tablesCfg) || empty($tablesCfg)) { + return [ + '_co_person_id' => '_person_id', + '_co_group_id' => '_group_id', + // Also support exact-form defaults for standalone columns + 'co_person_id' => 'person_id', + 'co_group_id' => 'group_id', + ]; + } + + // Filter out documentation keys and reduce to table entries that have a 'source' + $entries = []; + foreach ($tablesCfg as $k => $v) { + if (!is_array($v)) { continue; } + if (is_string($k) && str_starts_with($k, '__')) { continue; } + if (!isset($v['source']) || !is_string($v['source'])) { continue; } + $entries[$k] = $v['source']; } + + $patterns = [ + // suffix form (covers prefix + co_* cases) + '_co_person_id' => '_person_id', + '_co_group_id' => '_group_id', + // exact form (covers exact co_* columns with no left prefix) + 'co_person_id' => 'person_id', + 'co_group_id' => 'group_id', + ]; + + foreach ($entries as $targetKey => $src) { + $bareSource = $src; + if ($sourcePrefix !== '' && str_starts_with($bareSource, $sourcePrefix)) { + $bareSource = substr($bareSource, strlen($sourcePrefix)); + } + + // If bare source starts with "co_", derive both forms for this target + // targetKey 'message_templates' -> singular 'message_template' + if (str_starts_with($bareSource, 'co_')) { + $singular = \Cake\Utility\Inflector::singularize($targetKey); + + // suffix form: ..._co__id -> ...__id + $fromSuffix = '_co_' . $singular . '_id'; + $toSuffix = '_' . $singular . '_id'; + $patterns[$fromSuffix] = $toSuffix; + + // exact form: co__id -> _id + $fromExact = 'co_' . $singular . '_id'; + $toExact = $singular . '_id'; + $patterns[$fromExact] = $toExact; + } + } + + return $patterns; + } + + + /** + * Normalize a v4 source column name to a likely v5 target column name based on: + * - generic co_* foreign key patterns derived from migration config + * - specific one-offs + */ + private function normalizeTargetNameForSourceColumn(string $sourceCol, array $legacyFkPatterns): ?string + { + // Try exact-form first (eg, 'co_message_template_id' -> 'message_template_id') + if (isset($legacyFkPatterns[$sourceCol])) { + return $legacyFkPatterns[$sourceCol]; + } + + // Then apply suffix-based rewrites (eg, 'approver_co_group_id' -> 'approver_group_id') + foreach ($legacyFkPatterns as $from => $to) { + if ($from === $sourceCol) { continue; } // already handled exact + // only treat entries starting with '_' as suffix rules + if (str_starts_with($from, '_') && str_ends_with($sourceCol, $from)) { + $prefix = substr($sourceCol, 0, -strlen($from)); + return $prefix . $to; + } + } + + // Specific one-offs + if ($sourceCol === 'source_url') { + return 'source'; + } + if ($sourceCol === 'email_body') { + return 'email_body_text'; + } + + return null; + } } diff --git a/app/plugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php index 48a92b9e0..8ddf6c05c 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php @@ -44,7 +44,7 @@ private function runPreTableHook(string $table): void { throw new \RuntimeException("Unknown preTable hook: $method"); } - $this->io->verbose('Running pre-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->cmdPrinter->verbose('Running pre-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); $this->{$method}(); } @@ -62,7 +62,7 @@ private function runPostTableHook(string $table): void { if(!method_exists($this, $method)) { throw new \RuntimeException("Unknown postTable hook: $method"); } - $this->io->verbose('Running post-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->cmdPrinter->verbose('Running post-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); $this->{$method}(); } @@ -82,7 +82,7 @@ private function runPreRowHook(string $table, array &$origRow, array &$row): voi if(!method_exists($this, $method)) { throw new \RuntimeException("Unknown preRow hook: $method"); } - $this->io->verbose('Running pre-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->cmdPrinter->verbose('Running pre-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); $this->{$method}($origRow, $row); } @@ -102,7 +102,7 @@ private function runPostRowHook(string $table, array &$origRow, array &$row): vo if(!method_exists($this, $method)) { throw new \RuntimeException("Unknown postRow hook: $method"); } - $this->io->verbose('Running post-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->cmdPrinter->verbose('Running post-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); $this->{$method}($origRow, $row); } @@ -123,7 +123,7 @@ private function runSqlSelectHook(string $table, string $qualifiedTableName): st if(!method_exists(RawSqlQueries::class, $method)) { throw new \RuntimeException("Unknown sqlSelect hook: $method"); } - $this->io->verbose('Running SQL select hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->cmdPrinter->verbose('Running SQL select hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); // Special handling for MVEA-style selects: derive FK columns from fieldMap keys dynamically if ($method === 'mveaSqlSelect') { diff --git a/app/plugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php index 2afe257bf..8fcc56395 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php @@ -67,7 +67,7 @@ protected function createOwnersGroups(): void } } catch(\Exception $e) { - $this->io->error("Failed to create owners group for " + $this->cmdPrinter->error("Failed to create owners group for " . $group->name . " (" . $group->id . "): " . $e->getMessage()); } diff --git a/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php index 5516bbda1..6d5b0943a 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php @@ -76,7 +76,7 @@ protected function applyCheckGroupNameARRule(array $origRow, array &$row): void throw new \InvalidArgumentException('Replacing ":" produced an empty Standard CoGroup name; adjust --groups-colon-replacement'); } $row['name'] = $newName; - $this->io?->verbose(sprintf('Replaced ":" in Standard CoGroup name "%s" -> "%s"', $name, $newName)); + $this->cmdPrinter?->verbose(sprintf('Replaced ":" in Standard CoGroup name "%s" -> "%s"', $name, $newName)); } else { // Default: error out (no auto-replacement) throw new \InvalidArgumentException('Standard CoGroup names cannot contain a colon by default: ' . $name); @@ -123,7 +123,7 @@ protected function reconcileGroupMembershipOwnership(array $origRow, array &$row $qualifiedTableName = $this->outconn->qualifyTableName($tableName); $this->outconn->insert($qualifiedTableName, $ownerRow); } else { - $this->io->error("Could not find owners group for CoGroupMember " . $origRow['id']); + $this->cmdPrinter->error("Could not find owners group for CoGroupMember " . $origRow['id']); } } @@ -304,7 +304,7 @@ protected function migrateExtendedAttributesToAdHocAttributes(): void $qualifiedTableName = $this->outconn->qualifyTableName($tableName); $this->outconn->insert($qualifiedTableName, $adhocRow); } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) { - $this->io->warning("record already exists: " . print_r($adhocRow, true)); + $this->cmdPrinter->warning("record already exists: " . print_r($adhocRow, true)); } } } @@ -369,7 +369,7 @@ protected function mapExternalIdentityToExternalIdentityRole(array $origRow, arr ); } } catch (\Exception $e) { - $this->io->warning("Failed to map affiliation type: " . $e->getMessage()); + $this->cmdPrinter->warning("Failed to map affiliation type: " . $e->getMessage()); if ( isset($origRow['co_id']) && (!isset($this->cache['cos'][$origRow['co_id']]) diff --git a/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php index f96dec0aa..c6cfdbb33 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php @@ -239,7 +239,7 @@ protected function mapLoginIdentifiers(array $origRow, array &$row): void { $qualifiedTableName = $this->outconn->qualifyTableName($tableName); $this->outconn->insert($qualifiedTableName, $copiedRow); } catch (UniqueConstraintViolationException $e) { - $this->io->warning("record already exists: " . print_r($copiedRow, true)); + $this->cmdPrinter->warning("record already exists: " . print_r($copiedRow, true)); } } } @@ -308,7 +308,7 @@ protected function mapOrgIdentitycoPersonId(array $row): ?int $this->cache['org_identities']['co_people'] = []; // Build cache on first use - $this->io->verbose('Populating org identity map...'); + $this->cmdPrinter->verbose('Populating org identity map...'); $tableName = 'cm_co_org_identity_links'; $changelogFK = 'co_org_identity_link_id'; @@ -332,7 +332,7 @@ protected function mapOrgIdentitycoPersonId(array $row): ?int // improper unpooling from a legacy deployment. We'll accept only the // first record and throw warnings on the others. - $this->io->verbose("Found existing CO Person for Org Identity " . $oid . ", skipping"); + $this->cmdPrinter->verbose("Found existing CO Person for Org Identity " . $oid . ", skipping"); } else { // Store as-is; we'll resolve the latest revision on lookup $this->cache['org_identities']['co_people'][ $oid ][ $rowRev ] = (int)$r['co_person_id']; diff --git a/app/plugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php b/app/plugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php index 0ad16e8d3..06ae4ff25 100644 --- a/app/plugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php +++ b/app/plugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php @@ -5,105 +5,314 @@ use Cake\Console\ConsoleIo; /** - * DualAreaProgress + * Handles console output formatting including progress bars and colored messages * - * Keeps a progress bar on the top line and logs messages beneath it. - * Works with plain STDOUT or Cake's ConsoleIo. Uses ANSI escape codes. + * @since COmanage Registry v5.2.0 */ class CommandLinePrinter { + + /** + * Console IO instance for handling input/output + * + * @var ConsoleIo|null + * @since COmanage Registry v5.2.0 + */ private ?ConsoleIo $io; + + /** + * Total number of steps in the progress + * + * @var int + * @since COmanage Registry v5.2.0 + */ private int $total = 0; + + /** + * Current step in the progress + * + * @var int + * @since COmanage Registry v5.2.0 + */ private int $current = 0; - private int $messageLines = 0; // how many newline-terminated lines printed under the bar - private string $barColor; // 'blue' or 'green' + + /** + * Number of message lines printed + * + * @var int + * @since COmanage Registry v5.2.0 + */ + private int $messageLines = 0; + + /** + * Color of the progress bar + * + * @var string + * @since COmanage Registry v5.2.0 + */ + private string $barColor; + + /** + * Width of the progress bar in characters + * + * @var int + * @since COmanage Registry v5.2.0 + */ private int $barWidth; + + /** + * Whether to use ANSI colors in output + * + * @var bool + * @since COmanage Registry v5.2.0 + */ private bool $useColors; + /** + * Whether verbose output is enabled + * + * @var bool + * @since COmanage Registry v5.2.0 + */ + private bool $verbose; + + /** + * Whether the progress bar is currently active + * + * @var bool + * @since COmanage Registry v5.2.0 + */ + private bool $barActive = false; + + /** + * Constructor + * + * @param ConsoleIo|null $io Console IO instance for handling input/output + * @param string $barColor Color of the progress bar ('blue' or 'green') + * @param int $barWidth Width of the progress bar in characters + * @param bool $useColors Whether to use ANSI colors in output + * @since COmanage Registry v5.2.0 + */ public function __construct(?ConsoleIo $io = null, string $barColor = 'blue', int $barWidth = 50, bool $useColors = true) { $this->io = $io; $this->barColor = in_array($barColor, ['blue', 'green'], true) ? $barColor : 'blue'; $this->barWidth = max(10, $barWidth); $this->useColors = $useColors; + // Try to detect verbose mode from ConsoleIo if available + $this->verbose = $this->detectVerboseFromIo(); + } - /** Initialize the two-area layout and draw the 0% bar */ + /** + * Start displaying a new progress bar + * + * @param int $total Total number of steps + * @return void + * @since COmanage Registry v5.2.0 + */ public function start(int $total): void { + // When verbose is enabled, do not draw the progress bar at all + if ($this->verbose) { + return; + } + $this->total = max(0, $total); $this->current = 0; $this->messageLines = 0; + $this->barActive = true; + + // Hard reset the current line, then move to a brand new line + // \r -> move to column 0 + // \033[K -> clear to end of line + // \n -> go to next line + $this->rawWrite("\r\033[2K"); - // Draw initial progress bar line (without trailing newline) - $this->rawWrite("\r" . $this->formatBar(0)); + // Draw initial progress bar line (no leading \r, we are already at col 0) + $this->rawWrite($this->formatBar(0)); // Save cursor position at the end of the progress bar line $this->rawWrite("\033[s"); - // Move cursor back down ONLY if messages exist - if ($this->messageLines > 0) { - $this->rawWrite("\033[" . $this->messageLines . "B\r"); - } + // Move to message area (one line below the bar) + $this->rawWrite("\033[1B\r"); } - /** Update the progress bar (0..total). Call as work advances. */ + /** + * Update the progress bar to show current progress + * + * @param int $current Current step number + * @return void + * @since COmanage Registry v5.2.0 + */ public function update(int $current): void { + // When verbose is enabled, do not draw the progress bar at all + if ($this->verbose || !$this->barActive) { + return; + } + $this->current = min(max(0, $current), $this->total); - // Restore to saved position (progress bar line), redraw, save again - $this->rawWrite("\033[u"); // restore saved cursor (bar line) + // Restore to bar line, redraw bar, save, then go back to message area + $this->rawWrite("\033[u"); // restore saved cursor (bar line end) $this->rawWrite("\r" . $this->formatBar($this->current)); - $this->rawWrite("\033[s"); // re-save position at end of bar - - // Move cursor back down to where messages continue - if ($this->messageLines > 0) { - $this->rawWrite("\033[" . $this->messageLines . "B\r"); - } else { - // exactly one line below the bar is the first message line - $this->rawWrite("\033[1B\r"); - } + $this->rawWrite("\033[s"); // save again at end of bar + $down = 1 + $this->messageLines; // one below bar + existing messages + $this->rawWrite("\033[" . $down . "B\r"); } - /** Finish: force bar to 100% and add a newline separating it from any further output */ + /** + * Complete and cleanup the progress bar display + * + * @return void + * @since COmanage Registry v5.2.0 + */ public function finish(): void { + // When verbose is enabled, do not draw the progress bar at all + if ($this->verbose || !$this->barActive) { + return; + } + $this->update($this->total); - // Move cursor to end of messages (already there), and ensure a newline after the last message block - $this->rawWrite(PHP_EOL); + + // Move the cursor to the line AFTER the whole message area, + // so subsequent output doesn't overwrite the bar. + $this->rawWrite("\033[u"); // restore saved position at end of bar line + $down = 1 + $this->messageLines; // one below bar + all message lines + $this->rawWrite("\033[" . $down . "B\r"); // move down + $this->rawWrite(PHP_EOL); // clean newline below + + // Re-anchor saved cursor to this clean line so future restores are safe + $this->rawWrite("\033[s"); + + // Reset internal counters so next run starts fresh + $this->messageLines = 0; + $this->barActive = false; + $this->total = 0; + $this->current = 0; } - // Convenience wrappers for messages under the bar - public function info(string $message): void { $this->message($message, 'info'); } - public function warn(string $message): void { $this->message($message, 'warn'); } - public function error(string $message): void { $this->message($message, 'error'); } - public function debug(string $message): void { $this->message($message, 'debug'); } + /** + * Display an info level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function info(string $message): void + { + $this->message($message, 'info'); + } + + /** + * Display a warning level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function warn(string $message): void + { + $this->message($message, 'warn'); + } - /** Print a message under the bar. Handles multi-line strings. */ + /** + * Display a warning level message (alias of warn()) + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function warning(string $message): void + { + $this->message($message, 'warn'); + } + + /** + * Display an error level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function error(string $message): void + { + $this->message($message, 'error'); + } + + /** + * Display a debug level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function debug(string $message): void + { + $this->message($message, 'debug'); + } + + /** + * Display a verbose level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function verbose(string $message): void { $this->message($message, 'verbose'); } + + /** + * Display a message with the specified level + * + * @param string $message Message to display + * @param string $level Message level (info, warn, error, debug, verbose) + * @return void + * @since COmanage Registry v5.2.0 + */ public function message(string $message, string $level = 'info'): void { + // Suppress verbose messages unless verbose mode is enabled + if (strtolower($level) === 'verbose' && $this->verbose !== true) { + return; + } + $lines = $this->colorizeLevel($level, $message); // Ensure the message ends with a newline so we can count line advances if ($lines === '' || substr($lines, -1) !== "\n") { $lines .= "\n"; } - // If this is the first message, create the message area by moving to the next line - if ($this->messageLines === 0) { - $this->rawWrite(PHP_EOL); - } - $this->rawWrite($lines); + if ($this->barActive) { + // Always print below the bar + $this->rawWrite("\033[u"); // restore to end of bar line + $down = 1 + $this->messageLines; + $this->rawWrite("\033[" . $down . "B"); // move to message area line + // Clear the entire message line and reset cursor to column 0 + $this->rawWrite("\r\033[2K"); + $this->rawWrite($lines); + $this->messageLines += substr_count($lines, "\n"); - // Count how many terminal lines were added based on newlines seen. - // Note: This doesn’t account for soft-wrapped lines; keep messages reasonably short. - $this->messageLines += substr_count($lines, "\n"); + // Return to bar line and re-save for next update + $this->rawWrite("\033[u"); + $this->rawWrite("\033[s"); + } else { + // No bar: clear current line completely and print from column 0 + $this->rawWrite("\r\033[2K"); + $this->rawWrite($lines); + } } /** - * Prompt the user for input while keeping the bar/message layout intact. - * Returns the provided answer (or null on EOF). + * Prompt for user input + * + * @param string $prompt Prompt to display + * @param string|null $default Default value if no input provided + * @return string|null User input or default value + * @since COmanage Registry v5.2.0 */ public function ask(string $prompt, ?string $default = null): ?string { @@ -142,38 +351,55 @@ public function ask(string $prompt, ?string $default = null): ?string } /** - * Convenience: prompt to continue (ENTER). + * Pause execution until user presses enter + * + * @param string $prompt Prompt to display + * @return void + * @since COmanage Registry v5.2.0 */ public function pause(string $prompt = 'Press to continue...'): void { $this->ask($prompt, ''); } - + /** + * Add color formatting to a message based on its level + * + * @param string $level Message level + * @param string $message Message to colorize + * @return string Formatted message + * @since COmanage Registry v5.2.0 + */ private function colorizeLevel(string $level, string $message): string { $level = strtolower($level); $prefix = ''; - $color = null; switch ($level) { case 'warn': case 'warning': $prefix = '[WARN] '; - $color = 'yellow'; break; case 'error': $prefix = '[ERROR] '; - $color = 'red'; break; case 'debug': $prefix = '[DEBUG] '; - $color = 'cyan'; break; + case 'verbose': + $prefix = '[VERBOSE] '; + break; + default: $prefix = '[INFO] '; - $color = null; // default terminal color } + // For INFO, render the label (up to the first colon) in white, value unchanged (or green if white info default) + if ($level === 'info') { + $formatted = $this->useColors ? $this->formatInfoLabelWhite($message) : $message; + return $prefix . $formatted; + } + + $color = $this->defaultColorForLevel($level); $text = $prefix . $message; if ($this->useColors && $color) { return $this->wrapColor($text, $color); @@ -181,6 +407,31 @@ private function colorizeLevel(string $level, string $message): string return $text; } + /** + * Get the default color for a message level + * + * @param string $level Message level + * @return string|null Color name or null if no color + * @since COmanage Registry v5.2.0 + */ + private function defaultColorForLevel(string $level): ?string + { + $level = strtolower($level); + return match ($level) { + 'warn', 'warning' => 'yellow', + 'error' => 'red', + 'debug' => 'cyan', + default => 'white', + }; + } + + /** + * Format the progress bar string + * + * @param int $current Current progress value + * @return string Formatted progress bar + * @since COmanage Registry v5.2.0 + */ private function formatBar(int $current): string { $total = max(1, $this->total); @@ -205,6 +456,14 @@ private function formatBar(int $current): string return $bar; } + /** + * Wrap text in ANSI color codes + * + * @param string $text Text to colorize + * @param string $color Color name + * @return string Color-wrapped text + * @since COmanage Registry v5.2.0 + */ private function wrapColor(string $text, string $color): string { $map = [ @@ -214,12 +473,20 @@ private function wrapColor(string $text, string $color): string 'blue' => '0;34', 'magenta'=> '0;35', 'cyan' => '0;36', + 'white' => '0;39', ]; $code = $map[$color] ?? null; if (!$code) { return $text; } return "\033[{$code}m{$text}\033[0m"; } + /** + * Write raw string to output + * + * @param string $str String to write + * @return void + * @since COmanage Registry v5.2.0 + */ private function rawWrite(string $str): void { if ($this->io) { @@ -230,4 +497,49 @@ private function rawWrite(string $str): void echo $str; } } + + /** + * Format info message with white label + * + * @param string $message Message to format + * @return string Formatted message + * @since COmanage Registry v5.2.0 + */ + private function formatInfoLabelWhite(string $message): string + { + $lines = explode("\n", $message); + foreach ($lines as $i => $line) { + if ($line === '') { continue; } + if (preg_match('/^([^:\r\n]+:)(.*)$/', $line, $m)) { + $second = $m[2]; + if ($this->useColors && $this->defaultColorForLevel('info') === 'white' && $second !== '') { + $second = $this->wrapColor($second, 'green'); + } + $lines[$i] = $this->wrapColor($m[1], 'white') . $second; + } + } + return implode("\n", $lines); + } + + /** + * Detect verbose mode from ConsoleIO instance + * + * @return bool Whether verbose mode is enabled + * @since COmanage Registry v5.2.0 + */ + private function detectVerboseFromIo(): bool + { + if ($this->io === null) { + return false; + } + if (method_exists($this->io, 'level')) { + try { + $level = (int)$this->io->level(); + return $level >= 2; // VERBOSE + } catch (\Throwable $e) { + return false; + } + } + return false; + } } diff --git a/app/plugins/Transmogrify/src/Lib/Util/RawSqlQueries.php b/app/plugins/Transmogrify/src/Lib/Util/RawSqlQueries.php index 8baeb8f2d..d716c5736 100644 --- a/app/plugins/Transmogrify/src/Lib/Util/RawSqlQueries.php +++ b/app/plugins/Transmogrify/src/Lib/Util/RawSqlQueries.php @@ -131,7 +131,7 @@ public static function buildSequenceReset(string $qualifiedTableName, int $nextI * @param DBALConnection $outconn Connection to target database * @param string $sourceTable Name of source table * @param string $targetTable Name of target table - * @param ConsoleIo $cio Console IO object for output + * @param CommandLinePrinter CommandLinePrinter IO object for output * @return bool True if sequence was reset successfully, false otherwise * @since COmanage Registry v5.2.0 */ @@ -140,14 +140,14 @@ public static function setSequenceId( DBALConnection $outconn, string $sourceTable, string $targetTable, - ConsoleIo $cio, + CommandLinePrinter $cmdPrinter, ): bool { $qualifiedTableName = $inconn->qualifyTableName($sourceTable); $maxId = $inconn->fetchOne(self::buildSelectMaxId($qualifiedTableName)); $maxId = ((int)($maxId ?? 0)) + 1; $qualifiedTableName = $outconn->qualifyTableName($targetTable); - $cio->info("Resetting primary key sequence for $qualifiedTableName to $maxId"); + $cmdPrinter->info("Resetting primary key sequence for $qualifiedTableName to $maxId"); // Strictly speaking we should use prepared statements, but we control the // data here, and also we're executing a maintenance operation (so query