From 8017c7b02175c5b13eb4e28d1eb772e8a51666b9 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:20:50 +0200 Subject: [PATCH 01/15] feat(import): add StructureDiffService for table/view structure comparison AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/StructureDiffService.php | 351 +++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 lib/Service/StructureDiffService.php diff --git a/lib/Service/StructureDiffService.php b/lib/Service/StructureDiffService.php new file mode 100644 index 0000000000..bdb51fcc71 --- /dev/null +++ b/lib/Service/StructureDiffService.php @@ -0,0 +1,351 @@ +validateSchemeStructure($scheme); + $this->enforceArraySizeLimits($scheme); + + $table = $this->tableService->find($tableId, $this->userId); + $targetColumns = $this->columnService->findAllByTable($tableId, $this->userId); + $targetViews = $this->viewService->findAll($table, $this->userId); + + $diff = []; + + $versionWarning = $this->buildVersionWarning($scheme['tablesVersion'] ?? ''); + if ($versionWarning !== null) { + $diff['versionWarning'] = $versionWarning; + } + + $tableMeta = $this->diffTableMeta($table, $scheme); + if (!empty($tableMeta['changes'])) { + $diff['tableMeta'] = $tableMeta; + } + + [$columnDiff, $columnMap] = $this->diffColumns($targetColumns, $scheme['columns']); + if (!empty($columnDiff)) { + $diff['columns'] = $columnDiff; + } + + $viewDiff = $this->diffViews($targetViews, $scheme['views'] ?? [], $columnMap, $scheme['columns']); + if (!empty($viewDiff)) { + $diff['views'] = $viewDiff; + } + + $diff['columnMap'] = $columnMap; + + return $diff; + } + + /** + * @throws BadRequestError + */ + private function validateSchemeStructure(array $scheme): void { + if (!isset($scheme['columns'], $scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: missing required keys "columns" or "views".'); + } + if (!is_array($scheme['columns'])) { + throw new BadRequestError('Invalid scheme structure: "columns" must be an array.'); + } + if (!is_array($scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: "views" must be an array.'); + } + foreach ($scheme['columns'] as $col) { + if (!is_array($col) + || !isset($col['id'], $col['title'], $col['type']) + || !is_int($col['id']) + || !is_string($col['title']) + || !is_string($col['type']) + ) { + throw new BadRequestError('Invalid scheme structure: each column must be an array with integer "id", string "title" and string "type".'); + } + } + } + + /** + * @throws BadRequestError + */ + private function enforceArraySizeLimits(array $scheme): void { + if (count($scheme['columns']) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columns" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($scheme['views'] ?? []) > self::MAX_VIEWS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "views" must not exceed ' . self::MAX_VIEWS . ' entries.'); + } + } + + private function buildVersionWarning(?string $schemeVersion): ?string { + if ($schemeVersion === null || $schemeVersion === '') { + return null; + } + $appVersion = $this->appManager->getAppVersion('tables'); + $schemeMajor = (int)explode('.', $schemeVersion)[0]; + $appMajor = (int)explode('.', $appVersion)[0]; + if ($schemeMajor !== $appMajor) { + return sprintf( + 'Scheme was generated by Tables %s; this instance runs %s. Verify the result carefully.', + $schemeVersion, + $appVersion, + ); + } + return null; + } + + private function diffTableMeta(\OCA\Tables\Db\Table $table, array $scheme): array { + $changes = []; + $fields = ['title', 'emoji', 'description']; + $getters = ['title' => 'getTitle', 'emoji' => 'getEmoji', 'description' => 'getDescription']; + foreach ($fields as $field) { + if (!array_key_exists($field, $scheme)) { + continue; + } + $current = $table->{$getters[$field]}(); + $incoming = $scheme[$field]; + if ($current !== $incoming) { + $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; + } + } + return ['changes' => $changes]; + } + + /** + * @param \OCA\Tables\Db\Column[] $targetColumns + * @param array $sourceColumns + * @return array{0: array, 1: array} + */ + private function diffColumns(array $targetColumns, array $sourceColumns): array { + $columnDiff = []; + $columnMap = []; + + $targetByKey = []; + foreach ($targetColumns as $col) { + $key = strtolower($col->getTitle()) . '|' . $col->getType(); + $targetByKey[$key] = $col; + } + + $matchedTargetIds = []; + + foreach ($sourceColumns as $srcCol) { + $key = strtolower($srcCol['title']) . '|' . $srcCol['type']; + if (isset($targetByKey[$key])) { + $targetCol = $targetByKey[$key]; + $matchedTargetIds[] = $targetCol->getId(); + $columnMap[] = [ + 'sourceName' => $srcCol['title'], + 'sourceType' => $srcCol['type'], + 'sourceId' => $srcCol['id'], + 'targetId' => $targetCol->getId(), + ]; + $changes = $this->diffColumnProperties($targetCol, $srcCol); + if (!empty($changes)) { + $columnDiff[] = [ + 'action' => 'update', + 'sourceId' => $srcCol['id'], + 'targetId' => $targetCol->getId(), + 'title' => $srcCol['title'], + 'type' => $srcCol['type'], + 'changes' => $changes, + ]; + } + } else { + $columnMap[] = [ + 'sourceName' => $srcCol['title'], + 'sourceType' => $srcCol['type'], + 'sourceId' => $srcCol['id'], + 'targetId' => null, + ]; + $columnDiff[] = [ + 'action' => 'add', + 'sourceId' => $srcCol['id'], + 'title' => $srcCol['title'], + 'type' => $srcCol['type'], + 'details' => $srcCol, + ]; + } + } + + foreach ($targetColumns as $targetCol) { + if (!in_array($targetCol->getId(), $matchedTargetIds, true)) { + $columnDiff[] = [ + 'action' => 'delete', + 'targetId' => $targetCol->getId(), + 'title' => $targetCol->getTitle(), + 'type' => $targetCol->getType(), + ]; + } + } + + return [$columnDiff, $columnMap]; + } + + private function diffColumnProperties(\OCA\Tables\Db\Column $targetCol, array $srcCol): array { + $changes = []; + $targetArr = $targetCol->jsonSerialize(); + foreach (self::COLUMN_DIFFABLE_FIELDS as $field) { + $current = $targetArr[$field] ?? null; + $incoming = $srcCol[$field] ?? null; + if ($field === 'selectionOptions' && is_array($incoming)) { + $incoming = json_encode($incoming); + } + if ($current !== $incoming) { + $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; + } + } + return $changes; + } + + /** + * @param \OCA\Tables\Db\View[] $targetViews + * @param array $sourceViews + * @param array $columnMap + * @param array $sourceColumns + * @return array + */ + private function diffViews(array $targetViews, array $sourceViews, array $columnMap, array $sourceColumns): array { + $viewDiff = []; + + $targetByTitle = []; + foreach ($targetViews as $view) { + $targetByTitle[$view->getTitle()] = $view; + } + + $sourceColumnIds = array_column($sourceColumns, 'id'); + $overriddenSelectionColumnIds = $this->findOverriddenSelectionColumnIds($columnMap); + + foreach ($sourceViews as $srcView) { + $title = $srcView['title'] ?? ''; + if (!isset($targetByTitle[$title])) { + $viewDiff[] = [ + 'action' => 'add', + 'title' => $title, + 'details' => $srcView, + ]; + } else { + $targetView = $targetByTitle[$title]; + $changes = $this->diffViewProperties($targetView, $srcView); + if (!empty($changes)) { + $item = [ + 'action' => 'update', + 'title' => $title, + 'changes' => $changes, + ]; + if ($this->hasSelectionFilterWarning($srcView, $overriddenSelectionColumnIds, $sourceColumnIds)) { + $item['selectionFilterWarning'] = true; + } + $viewDiff[] = $item; + } + } + } + + return $viewDiff; + } + + private function diffViewProperties(\OCA\Tables\Db\View $targetView, array $srcView): array { + $changes = []; + $targetArr = $targetView->jsonSerialize(); + foreach (self::VIEW_DIFFABLE_FIELDS as $field) { + $current = $targetArr[$field] ?? null; + $incoming = $srcView[$field] ?? null; + if (is_array($current)) { + $current = json_encode($current); + } + if (is_array($incoming)) { + $incoming = json_encode($incoming); + } + if ($current !== $incoming) { + $changes[] = ['field' => $field, 'current' => $targetArr[$field] ?? null, 'incoming' => $srcView[$field] ?? null]; + } + } + return $changes; + } + + /** + * Returns the set of source column IDs whose selection options are being fully replaced. + * A column is considered "overridden" if it has an update action with a selectionOptions change. + */ + private function findOverriddenSelectionColumnIds(array $columnMap): array { + $overridden = []; + foreach ($columnMap as $entry) { + if ($entry['targetId'] !== null && $entry['sourceType'] === 'selection') { + $overridden[] = $entry['sourceId']; + } + } + return $overridden; + } + + /** + * Returns true when a view's filter JSON contains @selection-id-{id} tokens for columns + * whose selection options are being replaced. + */ + private function hasSelectionFilterWarning(array $srcView, array $overriddenSelectionColumnIds, array $allSourceColumnIds): bool { + if (empty($overriddenSelectionColumnIds)) { + return false; + } + $filterJson = is_array($srcView['filter'] ?? null) + ? json_encode($srcView['filter']) + : ($srcView['filter'] ?? ''); + if (!is_string($filterJson) || $filterJson === '') { + return false; + } + if (preg_match_all('/@selection-id-(\d+)/', $filterJson, $matches)) { + foreach ($overriddenSelectionColumnIds as $id) { + if (in_array((string)$id, $matches[1], true)) { + return true; + } + } + } + return false; + } +} From 6831c1de96fd569406d7940a343be47ffd07788a Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:24:36 +0200 Subject: [PATCH 02/15] feat(import): add POST /tables/{id}/scheme/diff endpoint AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- appinfo/routes.php | 2 + lib/Controller/ApiTablesController.php | 74 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..03e1f9af7d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -127,6 +127,8 @@ ['name' => 'ApiTables#update', 'url' => '/api/2/tables/{id}', 'verb' => 'PUT'], ['name' => 'ApiTables#destroy', 'url' => '/api/2/tables/{id}', 'verb' => 'DELETE'], ['name' => 'ApiTables#transfer', 'url' => '/api/2/tables/{id}/transfer', 'verb' => 'PUT'], + ['name' => 'ApiTables#schemeDiff', 'url' => '/api/2/tables/{id}/scheme/diff', 'verb' => 'POST'], + ['name' => 'ApiTables#applyScheme', 'url' => '/api/2/tables/{id}/scheme', 'verb' => 'PUT'], ['name' => 'ApiColumns#index', 'url' => '/api/2/columns/{nodeType}/{nodeId}', 'verb' => 'GET'], ['name' => 'ApiColumns#show', 'url' => '/api/2/columns/{id}', 'verb' => 'GET'], diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 7510929b37..dec943a9f1 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -10,6 +10,7 @@ use Exception; use OCA\Tables\AppInfo\Application; use OCA\Tables\Dto\Column as ColumnDto; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; @@ -18,12 +19,15 @@ use OCA\Tables\Model\SortRuleSet; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; +use OCA\Tables\Service\ApplySchemeService; use OCA\Tables\Service\ColumnService; +use OCA\Tables\Service\StructureDiffService; use OCA\Tables\Service\TableService; use OCA\Tables\Service\ViewService; use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\IDBConnection; use OCP\IL10N; @@ -41,6 +45,8 @@ class ApiTablesController extends AOCSController { private ViewService $viewService; private IAppManager $appManager; private IDBConnection $db; + private StructureDiffService $structureDiffService; + private ApplySchemeService $applySchemeService; public function __construct( IRequest $request, @@ -51,6 +57,8 @@ public function __construct( IL10N $n, IAppManager $appManager, IDBConnection $db, + StructureDiffService $structureDiffService, + ApplySchemeService $applySchemeService, string $userId) { parent::__construct($request, $logger, $n, $userId); $this->service = $service; @@ -58,6 +66,8 @@ public function __construct( $this->appManager = $appManager; $this->viewService = $viewService; $this->db = $db; + $this->structureDiffService = $structureDiffService; + $this->applySchemeService = $applySchemeService; } /** @@ -124,6 +134,70 @@ public function showScheme(int $id): DataResponse { } } + /** + * [api v2] Compute a structural diff between the current table state and an incoming scheme + * + * @param int $id Table ID + * @param array $scheme Incoming scheme JSON + * @return DataResponse|DataResponse + * + * 200: Diff returned + * 400: Bad request + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'id')] + #[UserRateLimit(limit: 20, period: 60)] + public function schemeDiff(int $id, array $scheme = []): DataResponse { + try { + return new DataResponse($this->structureDiffService->computeDiff($id, $scheme)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } + } + + /** + * [api v2] Apply selected structural changes from a scheme to an existing table atomically + * + * @param int $id Table ID + * @param array $scheme Incoming scheme JSON + * @param array $selection Selection payload describing which changes to apply + * @return DataResponse|DataResponse + * + * 200: Updated scheme returned + * 400: Bad request + * 403: No permissions + * 404: Not found + * 500: Internal error (failedStep included in response body) + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'id')] + #[UserRateLimit(limit: 20, period: 60)] + public function applyScheme(int $id, array $scheme = [], array $selection = []): DataResponse { + try { + return new DataResponse($this->applySchemeService->apply($id, $scheme, $selection)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + $this->logger->error('Failed to apply scheme: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage(), 'failedStep' => $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } + } + /** * creates table from scheme * From b3da7b94bb1d98cea1e6e124d796d8eea6dd5355 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:28:34 +0200 Subject: [PATCH 03/15] feat(import): add ApplySchemeService to apply selected structure changes atomically AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/ApplySchemeService.php | 512 +++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 lib/Service/ApplySchemeService.php diff --git a/lib/Service/ApplySchemeService.php b/lib/Service/ApplySchemeService.php new file mode 100644 index 0000000000..5a9dc7eec9 --- /dev/null +++ b/lib/Service/ApplySchemeService.php @@ -0,0 +1,512 @@ +validateSchemeStructure($scheme); + $this->enforceArraySizeLimits($scheme, $selection); + $this->enforceFieldWhitelists($selection); + $this->validateTextAllowedPatterns($scheme, $selection); + + // Cast all incoming IDs to int (SH-6) + $columnsAdd = array_map('intval', $selection['columnsAdd'] ?? []); + $columnsDelete = array_map('intval', $selection['columnsDelete'] ?? []); + $columnsUpdate = []; + foreach ($selection['columnsUpdate'] ?? [] as $rawId => $fields) { + $columnsUpdate[(int)$rawId] = $fields; + } + $viewsAdd = $selection['viewsAdd'] ?? []; + $viewsUpdate = $selection['viewsUpdate'] ?? []; + $tableMeta = $selection['tableMeta'] ?? []; + + // Index source columns by ID for fast lookup + $sourceColumnsById = []; + foreach ($scheme['columns'] as $col) { + $sourceColumnsById[(int)$col['id']] = $col; + } + + // Index source views by title for fast lookup + $sourceViewsByTitle = []; + foreach ($scheme['views'] ?? [] as $view) { + $sourceViewsByTitle[$view['title']] = $view; + } + + $failedStep = 'unknown'; + try { + return $this->atomic(function () use ( + $tableId, $scheme, $selection, + $columnsAdd, $columnsDelete, $columnsUpdate, + $viewsAdd, $viewsUpdate, $tableMeta, + $sourceColumnsById, $sourceViewsByTitle, + &$failedStep + ): array { + // Load owned columns and views for IDOR checks + $ownedColumns = $this->columnService->findAllByTable($tableId, $this->userId); + $ownedColumnIds = array_map(fn ($c) => $c->getId(), $ownedColumns); + + $table = $this->tableService->find($tableId, true, $this->userId); + $ownedViews = $this->viewService->findAll($table, $this->userId); + $ownedViewsByTitle = []; + foreach ($ownedViews as $view) { + $ownedViewsByTitle[$view->getTitle()] = $view; + } + + // 1. Table meta + if (!empty($tableMeta)) { + $failedStep = 'update table metadata'; + $title = in_array('title', $tableMeta, true) ? ($scheme['title'] ?? null) : null; + $emoji = in_array('emoji', $tableMeta, true) ? ($scheme['emoji'] ?? null) : null; + $description = in_array('description', $tableMeta, true) ? ($scheme['description'] ?? null) : null; + $this->tableService->update($tableId, $title, $emoji, $description, null, $this->userId); + } + + // 2. Add columns — track new sourceId → targetId mapping + $newColMap = []; + foreach ($columnsAdd as $sourceId) { + if (!isset($sourceColumnsById[$sourceId])) { + throw new BadRequestError("Source column ID {$sourceId} not found in scheme."); + } + $srcCol = $sourceColumnsById[$sourceId]; + $failedStep = "create column '{$srcCol['title']}'"; + $dto = $this->buildColumnDto($srcCol); + $newCol = $this->columnService->create($this->userId, $tableId, null, $dto); + $newColMap[$sourceId] = $newCol->getId(); + } + + // 3. Update columns + foreach ($columnsUpdate as $targetId => $fields) { + if (!in_array($targetId, $ownedColumnIds, true)) { + throw new PermissionError("Column ID {$targetId} does not belong to table {$tableId}."); + } + $srcCol = $this->findSourceColumnForTarget($targetId, $ownedColumns, $scheme['columns']); + if ($srcCol === null) { + throw new BadRequestError("Cannot find source column data for target column ID {$targetId}."); + } + $failedStep = "update column ID {$targetId}"; + $dto = $this->buildPartialColumnDto($srcCol, $fields); + $this->columnService->update($targetId, $this->userId, $dto); + } + + // 4. Delete columns + foreach ($columnsDelete as $targetId) { + if (!in_array($targetId, $ownedColumnIds, true)) { + throw new PermissionError("Column ID {$targetId} does not belong to table {$tableId}."); + } + $failedStep = "delete column ID {$targetId}"; + $this->columnService->delete($targetId, false, $this->userId); + } + + // Build combined column map (pre-existing matches + newly created) + $combinedColMap = $newColMap; + foreach ($scheme['columns'] as $srcCol) { + foreach ($ownedColumns as $tgtCol) { + if (strtolower($srcCol['title']) === strtolower($tgtCol->getTitle()) + && $srcCol['type'] === $tgtCol->getType() + ) { + $combinedColMap[(int)$srcCol['id']] = $tgtCol->getId(); + } + } + } + + // 5. Add views + foreach ($viewsAdd as $viewTitle) { + if (!isset($sourceViewsByTitle[$viewTitle])) { + throw new BadRequestError("Source view '{$viewTitle}' not found in scheme."); + } + $srcView = $sourceViewsByTitle[$viewTitle]; + $failedStep = "create view '{$viewTitle}'"; + $remappedView = $this->remapViewColumnIds($srcView, $combinedColMap, $viewTitle); + $newView = $this->viewService->create($viewTitle, $srcView['emoji'] ?? null, $table, $this->userId); + $updateData = $this->buildViewUpdateInput($remappedView); + $this->viewService->update($newView->getId(), $updateData, $this->userId); + } + + // 6. Update views + foreach ($viewsUpdate as $viewTitle => $fields) { + if (!isset($ownedViewsByTitle[$viewTitle])) { + throw new NotFoundError("View '{$viewTitle}' not found in table {$tableId}."); + } + $view = $ownedViewsByTitle[$viewTitle]; + if ($view->getTableId() !== $tableId) { + throw new NotFoundError("View '{$viewTitle}' does not belong to table {$tableId}."); + } + if (!isset($sourceViewsByTitle[$viewTitle])) { + throw new BadRequestError("Source view '{$viewTitle}' not found in scheme."); + } + $srcView = $sourceViewsByTitle[$viewTitle]; + $failedStep = "update view '{$viewTitle}'"; + // Only apply selected fields + $updatePayload = $this->buildPartialViewPayload($srcView, $fields); + $this->viewService->update($view->getId(), ViewUpdateInput::fromInputArray($updatePayload), $this->userId); + } + + // Reload and return full updated scheme + return $this->tableService->getScheme($tableId, $this->userId)->jsonSerialize(); + }, $this->dbc); + } catch (BadRequestError|PermissionError|NotFoundError $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error('ApplySchemeService failed at step "' . $failedStep . '": ' . $e->getMessage(), ['exception' => $e]); + throw new InternalError('Failed at step: ' . $failedStep . '. ' . $e->getMessage()); + } + } + + /** + * @throws BadRequestError + */ + private function validateSchemeStructure(array $scheme): void { + if (!isset($scheme['columns'], $scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: missing required keys "columns" or "views".'); + } + if (!is_array($scheme['columns']) || !is_array($scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: "columns" and "views" must be arrays.'); + } + foreach ($scheme['columns'] as $col) { + if (!is_array($col) + || !isset($col['id'], $col['title'], $col['type']) + || !is_int($col['id']) + || !is_string($col['title']) + || !is_string($col['type']) + ) { + throw new BadRequestError('Invalid scheme structure: each column must have integer "id", string "title" and string "type".'); + } + } + } + + /** + * @throws BadRequestError + */ + private function enforceArraySizeLimits(array $scheme, array $selection): void { + if (count($scheme['columns']) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columns" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($scheme['views'] ?? []) > self::MAX_VIEWS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "views" must not exceed ' . self::MAX_VIEWS . ' entries.'); + } + if (count($selection['columnsAdd'] ?? []) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columnsAdd" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($selection['columnsUpdate'] ?? []) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columnsUpdate" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($selection['columnsDelete'] ?? []) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columnsDelete" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($selection['viewsAdd'] ?? []) > self::MAX_VIEWS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "viewsAdd" must not exceed ' . self::MAX_VIEWS . ' entries.'); + } + if (count($selection['viewsUpdate'] ?? []) > self::MAX_VIEWS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "viewsUpdate" must not exceed ' . self::MAX_VIEWS . ' entries.'); + } + } + + /** + * @throws BadRequestError + */ + private function enforceFieldWhitelists(array $selection): void { + foreach ($selection['columnsUpdate'] ?? [] as $rawId => $fields) { + foreach ((array)$fields as $field) { + if (!in_array($field, self::ALLOWED_COLUMN_FIELDS, true)) { + throw new BadRequestError("Field \"{$field}\" is not allowed in columnsUpdate."); + } + } + } + foreach ($selection['viewsUpdate'] ?? [] as $viewTitle => $fields) { + foreach ((array)$fields as $field) { + if (!in_array($field, self::ALLOWED_VIEW_FIELDS, true)) { + throw new BadRequestError("Field \"{$field}\" is not allowed in viewsUpdate."); + } + } + } + } + + /** + * Validate textAllowedPattern for any column being added or updated (SH-1). + * @throws BadRequestError + */ + private function validateTextAllowedPatterns(array $scheme, array $selection): void { + $sourceColumnsById = []; + foreach ($scheme['columns'] as $col) { + $sourceColumnsById[(int)$col['id']] = $col; + } + + $idsToCheck = array_merge( + array_map('intval', $selection['columnsAdd'] ?? []), + array_keys(array_map('intval', array_keys($selection['columnsUpdate'] ?? []))) + ); + // Also check update targets — we need source columns for those + foreach ($selection['columnsUpdate'] ?? [] as $rawId => $fields) { + if (in_array('textAllowedPattern', (array)$fields, true)) { + // find source column matching this target by iterating scheme + foreach ($scheme['columns'] as $col) { + if (!empty($col['textAllowedPattern'])) { + $this->validateSinglePattern($col['textAllowedPattern']); + } + } + } + } + + foreach (array_map('intval', $selection['columnsAdd'] ?? []) as $sourceId) { + if (isset($sourceColumnsById[$sourceId]['textAllowedPattern']) + && $sourceColumnsById[$sourceId]['textAllowedPattern'] !== null + && $sourceColumnsById[$sourceId]['textAllowedPattern'] !== '' + ) { + $this->validateSinglePattern($sourceColumnsById[$sourceId]['textAllowedPattern']); + } + } + } + + /** + * @throws BadRequestError + */ + private function validateSinglePattern(string $pattern): void { + if (strlen($pattern) > 250) { + throw new BadRequestError('Column "textAllowedPattern" must not exceed 250 characters.'); + } + $result = @preg_match($pattern, ''); + if ($result === false) { + throw new BadRequestError('Column "textAllowedPattern" contains an invalid or unsafe regex pattern.'); + } + } + + private function buildColumnDto(array $srcCol): ColumnDto { + $selectionOptions = $srcCol['selectionOptions'] ?? null; + if (is_array($selectionOptions)) { + $selectionOptions = json_encode($selectionOptions); + } + $usergroupDefault = $srcCol['usergroupDefault'] ?? null; + if (is_array($usergroupDefault)) { + $usergroupDefault = $usergroupDefault[0] ?? ''; + } + $customSettings = $srcCol['customSettings'] ?? null; + if (is_array($customSettings)) { + $customSettings = json_encode($customSettings); + } + return new ColumnDto( + title: $srcCol['title'] ?? null, + type: $srcCol['type'] ?? null, + subtype: $srcCol['subtype'] ?? null, + mandatory: $srcCol['mandatory'] ?? null, + description: $srcCol['description'] ?? null, + textDefault: $srcCol['textDefault'] ?? null, + textAllowedPattern: $srcCol['textAllowedPattern'] ?? null, + textMaxLength: isset($srcCol['textMaxLength']) ? (int)$srcCol['textMaxLength'] : null, + textUnique: $srcCol['textUnique'] ?? null, + numberDefault: isset($srcCol['numberDefault']) ? (float)$srcCol['numberDefault'] : null, + numberMin: isset($srcCol['numberMin']) ? (float)$srcCol['numberMin'] : null, + numberMax: isset($srcCol['numberMax']) ? (float)$srcCol['numberMax'] : null, + numberDecimals: isset($srcCol['numberDecimals']) ? (int)$srcCol['numberDecimals'] : null, + numberPrefix: $srcCol['numberPrefix'] ?? null, + numberSuffix: $srcCol['numberSuffix'] ?? null, + selectionOptions: $selectionOptions === [] ? '' : $selectionOptions, + selectionDefault: $srcCol['selectionDefault'] ?? null, + datetimeDefault: $srcCol['datetimeDefault'] ?? null, + usergroupDefault: $usergroupDefault, + usergroupMultipleItems: $srcCol['usergroupMultipleItems'] ?? null, + usergroupSelectUsers: $srcCol['usergroupSelectUsers'] ?? null, + usergroupSelectGroups: $srcCol['usergroupSelectGroups'] ?? null, + usergroupSelectTeams: $srcCol['usergroupSelectTeams'] ?? null, + showUserStatus: $srcCol['showUserStatus'] ?? null, + customSettings: $customSettings, + ); + } + + /** + * Build a ColumnDto containing only the selected fields (for updates). + */ + private function buildPartialColumnDto(array $srcCol, array $fields): ColumnDto { + $data = []; + foreach ($fields as $field) { + if (array_key_exists($field, $srcCol)) { + $data[$field] = $srcCol[$field]; + } + } + // Normalize selectionOptions + if (isset($data['selectionOptions']) && is_array($data['selectionOptions'])) { + $data['selectionOptions'] = json_encode($data['selectionOptions']); + } + if (isset($data['usergroupDefault']) && is_array($data['usergroupDefault'])) { + $data['usergroupDefault'] = $data['usergroupDefault'][0] ?? ''; + } + if (isset($data['customSettings']) && is_array($data['customSettings'])) { + $data['customSettings'] = json_encode($data['customSettings']); + } + return ColumnDto::createFromArray($data); + } + + /** + * Remap source column IDs in a view's filter, sort, and columnSettings to target IDs. + * Does NOT remap @selection-id-{id} tokens (SH-8). + * + * @throws BadRequestError + */ + private function remapViewColumnIds(array $srcView, array $colMap, string $viewTitle): array { + $remapped = $srcView; + + // Remap filter column IDs + if (isset($remapped['filter']) && is_array($remapped['filter'])) { + foreach ($remapped['filter'] as &$filterGroup) { + foreach ($filterGroup as &$filter) { + if (isset($filter['columnId']) && (int)$filter['columnId'] > 0) { + $srcId = (int)$filter['columnId']; + if (!array_key_exists($srcId, $colMap)) { + throw new BadRequestError("View '{$viewTitle}' references unknown source column ID {$srcId}."); + } + $filter['columnId'] = $colMap[$srcId]; + } + } + } + } + + // Remap sort column IDs + if (isset($remapped['sort']) && is_array($remapped['sort'])) { + foreach ($remapped['sort'] as &$sort) { + if (isset($sort['columnId']) && (int)$sort['columnId'] > 0) { + $srcId = (int)$sort['columnId']; + if (!array_key_exists($srcId, $colMap)) { + throw new BadRequestError("View '{$viewTitle}' references unknown source column ID {$srcId}."); + } + $sort['columnId'] = $colMap[$srcId]; + } + } + } + + // Remap columnSettings column IDs + if (isset($remapped['columnSettings']) && is_array($remapped['columnSettings'])) { + foreach ($remapped['columnSettings'] as &$cs) { + if (isset($cs['columnId']) && (int)$cs['columnId'] > 0) { + $srcId = (int)$cs['columnId']; + if (!array_key_exists($srcId, $colMap)) { + throw new BadRequestError("View '{$viewTitle}' references unknown source column ID {$srcId}."); + } + $cs['columnId'] = $colMap[$srcId]; + } + } + } elseif (isset($remapped['columns']) && is_array($remapped['columns'])) { + foreach ($remapped['columns'] as &$colId) { + if ((int)$colId > 0) { + $srcId = (int)$colId; + if (!array_key_exists($srcId, $colMap)) { + throw new BadRequestError("View '{$viewTitle}' references unknown source column ID {$srcId}."); + } + $colId = $colMap[$srcId]; + } + } + } + + return $remapped; + } + + private function buildViewUpdateInput(array $srcView): ViewUpdateInput { + $payload = []; + foreach (['emoji', 'description', 'filter', 'sort', 'columnSettings', 'columns'] as $field) { + if (array_key_exists($field, $srcView)) { + $payload[$field] = $srcView[$field]; + } + } + return ViewUpdateInput::fromInputArray($payload); + } + + /** + * Build a partial view update payload containing only the selected fields. + */ + private function buildPartialViewPayload(array $srcView, array $fields): array { + $payload = []; + foreach ($fields as $field) { + if (array_key_exists($field, $srcView)) { + $payload[$field] = $srcView[$field]; + } + } + return $payload; + } + + /** + * Find the source column data that corresponds to the given target column ID. + * Matches by title+type (same logic as StructureDiffService). + * + * @param \OCA\Tables\Db\Column[] $ownedColumns + */ + private function findSourceColumnForTarget(int $targetId, array $ownedColumns, array $sourceColumns): ?array { + // Find the owned column object + $ownedCol = null; + foreach ($ownedColumns as $col) { + if ($col->getId() === $targetId) { + $ownedCol = $col; + break; + } + } + if ($ownedCol === null) { + return null; + } + // Find matching source column by title (case-insensitive) + type + foreach ($sourceColumns as $srcCol) { + if (strtolower($srcCol['title']) === strtolower($ownedCol->getTitle()) + && $srcCol['type'] === $ownedCol->getType() + ) { + return $srcCol; + } + } + return null; + } +} From cfbf252ebd74bbfc22d3feb4b952a403631f8927 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:30:48 +0200 Subject: [PATCH 04/15] feat(import): add 'Import structure' action to table menu AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../partials/NavigationTableItem.vue | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index c09498ee30..06f6bcb542 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -60,6 +60,17 @@ + + + {{ t('tables', 'Import structure') }} + + + + {{ t('tables', 'Export') }} @@ -137,8 +148,11 @@ + + diff --git a/src/modules/modals/Modals.vue b/src/modules/modals/Modals.vue index 4cf1b94c52..1fc1dce3a5 100644 --- a/src/modules/modals/Modals.vue +++ b/src/modules/modals/Modals.vue @@ -45,6 +45,12 @@ :show-modal="showImportScheme" :title="importSchemeTitle" @close="showImportScheme = false" /> + @@ -73,6 +79,7 @@ import TransferTable from './TransferTable.vue' import CreateContext from './CreateContext.vue' import TransferContext from './TransferContext.vue' import DeleteContext from './DeleteContext.vue' +import ImportStructurePreview from './ImportStructurePreview.vue' export default { components: { @@ -94,6 +101,7 @@ export default { EditContext, TransferContext, DeleteContext, + ImportStructurePreview, }, data() { @@ -118,6 +126,7 @@ export default { tableToTransfer: null, contextToTransfer: null, contextToDelete: null, + importStructureData: null, } }, @@ -155,6 +164,7 @@ export default { // misc subscribe('tables:modal:import', element => { this.importToElement = element }) subscribe('tables:modal:scheme', title => { this.importSchemeTitle = title; this.showImportScheme = true }) + subscribe('tables:modal:importStructure', data => { this.importStructureData = data }) // context subscribe('tables:context:create', () => { this.showModalCreateContext = true }) @@ -183,6 +193,7 @@ export default { }) unsubscribe('tables:table:create', () => { this.showModalCreateTable = true }) unsubscribe('tables:modal:import', element => { this.importToElement = element }) + unsubscribe('tables:modal:importStructure', data => { this.importStructureData = data }) unsubscribe('tables:table:delete', table => { this.tableToDelete = table }) unsubscribe('tables:table:edit', tableId => { this.editTable = tableId }) unsubscribe('tables:table:transfer', table => { this.tableToTransfer = table }) From b243e6043e7271455f37251f53b60fb8a35b1530 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:59:41 +0200 Subject: [PATCH 06/15] test(import): add PHPUnit tests for StructureDiffService AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../unit/Service/StructureDiffServiceTest.php | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 tests/unit/Service/StructureDiffServiceTest.php diff --git a/tests/unit/Service/StructureDiffServiceTest.php b/tests/unit/Service/StructureDiffServiceTest.php new file mode 100644 index 0000000000..3415773495 --- /dev/null +++ b/tests/unit/Service/StructureDiffServiceTest.php @@ -0,0 +1,304 @@ +createMock(LoggerInterface::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->tableService = $this->createMock(TableService::class); + $this->columnService = $this->createMock(ColumnService::class); + $this->viewService = $this->createMock(ViewService::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppVersion')->willReturn('0.9.0'); + + $this->service = new StructureDiffService( + $logger, + 'admin', + $this->permissionsService, + $this->tableService, + $this->columnService, + $this->viewService, + $this->appManager, + ); + } + + private function makeTable(): Table { + $table = new Table(); + $table->setTitle('Test Table'); + $table->setEmoji(''); + $table->setDescription(''); + return $table; + } + + private function makeColumn(int $id, string $title, string $type): Column { + $col = new Column(); + $col->setId($id); + $col->setTableId(1); + $col->setTitle($title); + $col->setType($type); + $col->setMandatory(false); + $col->setDescription(''); + return $col; + } + + private function makeView(string $title): View { + $view = new View(); + $view->setTableId(1); + $view->setTitle($title); + $view->setEmoji(null); + $view->setDescription(null); + $view->setFilter([]); + $view->setSort([]); + $view->setColumnSettings([]); + return $view; + } + + private function baseScheme(array $columns = [], array $views = [], array $extra = []): array { + return array_merge([ + 'columns' => $columns, + 'views' => $views, + ], $extra); + } + + private function sourceColumn(int $id, string $title, string $type, array $extra = []): array { + return array_merge(['id' => $id, 'title' => $title, 'type' => $type], $extra); + } + + // --- Validation tests --- + + public function testMissingColumnsKeyThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['views' => []]); + } + + public function testMissingViewsKeyThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => []]); + } + + public function testColumnsNotArrayThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => 'not-an-array', 'views' => []]); + } + + public function testColumnMissingIdThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, [ + 'columns' => [['title' => 'X', 'type' => 'text']], + 'views' => [], + ]); + } + + public function testColumnNonIntegerIdThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, [ + 'columns' => [['id' => '1', 'title' => 'X', 'type' => 'text']], + 'views' => [], + ]); + } + + public function testColumnsCountExceedingLimitThrows(): void { + $columns = []; + for ($i = 1; $i <= 501; $i++) { + $columns[] = ['id' => $i, 'title' => "Col $i", 'type' => 'text']; + } + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => $columns, 'views' => []]); + } + + // --- Column diff tests --- + + public function testAllColumnsIdenticalProducesNoDiff(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Name', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(1, 'Name', 'text'), + ])); + + self::assertArrayNotHasKey('columns', $result); + } + + public function testNewColumnInSourceProducesAddAction(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(5, 'Score', 'number'), + ])); + + self::assertCount(1, $result['columns']); + self::assertSame('add', $result['columns'][0]['action']); + self::assertSame('Score', $result['columns'][0]['title']); + } + + public function testMatchedColumnWithPropertyDiffProducesUpdateAction(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Name', 'text'); + $col->setDescription('old desc'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(99, 'Name', 'text', ['description' => 'new desc']), + ])); + + self::assertCount(1, $result['columns']); + $colDiff = $result['columns'][0]; + self::assertSame('update', $colDiff['action']); + self::assertSame(1, $colDiff['targetId']); + $fields = array_column($colDiff['changes'], 'field'); + self::assertContains('description', $fields); + } + + public function testTargetOnlyColumnProducesDeleteAction(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(3, 'OldCol', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([])); + + self::assertCount(1, $result['columns']); + self::assertSame('delete', $result['columns'][0]['action']); + self::assertSame('OldCol', $result['columns'][0]['title']); + } + + public function testSameNameDifferentTypeProducesAddAndDelete(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Status', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(2, 'Status', 'number'), + ])); + + $actions = array_column($result['columns'], 'action'); + self::assertContains('add', $actions); + self::assertContains('delete', $actions); + } + + // --- View diff tests --- + + public function testNewViewTitleInSourceProducesAddAction(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [ + ['title' => 'My View', 'filter' => [], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + self::assertCount(1, $result['views']); + self::assertSame('add', $result['views'][0]['action']); + self::assertSame('My View', $result['views'][0]['title']); + } + + public function testMatchedViewWithFilterDiffProducesUpdateAction(): void { + $table = $this->makeTable(); + $view = $this->makeView('My View'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([$view]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [ + ['title' => 'My View', 'filter' => [['columnId' => 1, 'operator' => 'is-equal', 'value' => 'x']], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + self::assertCount(1, $result['views']); + self::assertSame('update', $result['views'][0]['action']); + } + + public function testNoDiffAnywherePrducesEmptyDiff(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [])); + + self::assertArrayNotHasKey('columns', $result); + self::assertArrayNotHasKey('views', $result); + self::assertArrayNotHasKey('tableMeta', $result); + self::assertArrayNotHasKey('versionWarning', $result); + } + + public function testVersionMajorMismatchProducesVersionWarning(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + // appManager is already set to return '0.9.0'; scheme has major 1 + $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '1.0.0'])); + self::assertArrayHasKey('versionWarning', $result); + self::assertStringContainsString('1.0.0', $result['versionWarning']); + } + + public function testSameMajorVersionProducesNoVersionWarning(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '0.8.5'])); + self::assertArrayNotHasKey('versionWarning', $result); + } + + public function testSelectionColumnOverrideWithSelectionFilterProducesWarning(): void { + $table = $this->makeTable(); + $selCol = $this->makeColumn(10, 'Status', 'selection'); + $selCol->setSelectionOptions('[{"id":1,"label":"A"}]'); + $view = $this->makeView('Filter View'); + $view->setFilter([[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]]); + + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$selCol]); + $this->viewService->method('findAll')->willReturn([$view]); + + // Source column same title+type but different options (triggers update), source col ID = 99 + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(99, 'Status', 'selection', ['selectionOptions' => [['id' => 2, 'label' => 'B']]]), + ], [ + ['title' => 'Filter View', 'filter' => [[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + $viewUpdates = array_filter($result['views'] ?? [], fn ($v) => $v['action'] === 'update'); + self::assertNotEmpty($viewUpdates); + $viewUpdate = array_values($viewUpdates)[0]; + self::assertTrue($viewUpdate['selectionFilterWarning'] ?? false); + } +} From 007baf448a5009ec213819c7478f1614453aadec Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 14:59:51 +0200 Subject: [PATCH 07/15] test(import): add PHPUnit tests for ApplySchemeService AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- tests/unit/Service/ApplySchemeServiceTest.php | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tests/unit/Service/ApplySchemeServiceTest.php diff --git a/tests/unit/Service/ApplySchemeServiceTest.php b/tests/unit/Service/ApplySchemeServiceTest.php new file mode 100644 index 0000000000..843233954c --- /dev/null +++ b/tests/unit/Service/ApplySchemeServiceTest.php @@ -0,0 +1,296 @@ +createMock(LoggerInterface::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->tableService = $this->createMock(TableService::class); + $this->columnService = $this->createMock(ColumnService::class); + $this->viewService = $this->createMock(ViewService::class); + $this->dbc = $this->createMock(IDBConnection::class); + + // Make atomic() execute the callback directly + $this->dbc->method('beginTransaction')->willReturnSelf(); + $this->dbc->method('commit')->willReturnSelf(); + + $this->service = new ApplySchemeService( + $logger, + 'admin', + $this->permissionsService, + $this->tableService, + $this->columnService, + $this->viewService, + $this->dbc, + ); + } + + private function makeColumn(int $id, string $title, string $type): Column { + $col = new Column(); + $col->setId($id); + $col->setTableId(1); + $col->setTitle($title); + $col->setType($type); + $col->setMandatory(false); + $col->setDescription(''); + return $col; + } + + private function makeTable(): Table { + $table = new Table(); + $table->setId(1); + $table->setTitle('T'); + $table->setEmoji(''); + $table->setDescription(''); + return $table; + } + + private function makeView(int $id, string $title): View { + $view = new View(); + $view->setId($id); + $view->setTableId(1); + $view->setTitle($title); + $view->setEmoji(null); + $view->setDescription(null); + $view->setFilter([]); + $view->setSort([]); + $view->setColumnSettings([]); + return $view; + } + + private function baseScheme(array $columns = [], array $views = []): array { + return [ + 'title' => 'T', + 'emoji' => '', + 'description' => '', + 'columns' => $columns, + 'views' => $views, + ]; + } + + private function srcCol(int $id, string $title, string $type, array $extra = []): array { + return array_merge(['id' => $id, 'title' => $title, 'type' => $type], $extra); + } + + // --- Validation tests (pre-transaction) --- + + public function testMissingColumnsKeyThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->apply(1, ['views' => []], []); + } + + public function testColumnsUpdateWithNonWhitelistedFieldThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->apply(1, $this->baseScheme([$this->srcCol(1, 'A', 'text')]), [ + 'columnsUpdate' => [1 => ['tableId']], + ]); + } + + public function testViewsUpdateWithNonWhitelistedFieldThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->apply(1, $this->baseScheme([], [['title' => 'V', 'filter' => [], 'sort' => []]]), [ + 'viewsUpdate' => ['V' => ['createdBy']], + ]); + } + + public function testColumnsUpdateArrayExceedingLimitThrows(): void { + $columnsUpdate = []; + for ($i = 1; $i <= 501; $i++) { + $columnsUpdate[$i] = ['title']; + } + $this->expectException(BadRequestError::class); + $this->service->apply(1, $this->baseScheme(), ['columnsUpdate' => $columnsUpdate]); + } + + public function testInvalidRegexPatternInColumnsAddThrows(): void { + $scheme = $this->baseScheme([ + $this->srcCol(1, 'Notes', 'text', ['textAllowedPattern' => '(a+)+']), + ]); + // Valid regex but let's use a clearly invalid one + $scheme['columns'][0]['textAllowedPattern'] = '[invalid'; + $this->expectException(BadRequestError::class); + $this->service->apply(1, $scheme, ['columnsAdd' => [1]]); + } + + public function testStringTargetIdIsCastToIntForOwnedColumnCheck(): void { + $col = $this->makeColumn(7, 'A', 'text'); + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + $newCol = $this->makeColumn(99, 'A', 'text'); + $this->columnService->method('update')->willReturn($newCol); + + $scheme = $this->makeSchemeForColumnScheme($col); + $tableScheme = $this->createMock(\OCA\Tables\Model\TableScheme::class); + $tableScheme->method('jsonSerialize')->willReturn(['id' => 1]); + $this->tableService->method('getScheme')->willReturn($tableScheme); + + // String "7" should be cast to int 7, matching the owned column + $result = $this->service->apply(1, $scheme, ['columnsUpdate' => ['7' => ['description']]]); + self::assertIsArray($result); + } + + public function testForeignTargetIdInColumnsUpdateThrowsPermissionError(): void { + $col = $this->makeColumn(5, 'A', 'text'); + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + + $this->expectException(PermissionError::class); + $this->service->apply(1, $this->baseScheme([$this->srcCol(99, 'X', 'text')]), [ + 'columnsUpdate' => [999 => ['description']], + ]); + } + + public function testForeignTargetIdInColumnsDeleteThrowsPermissionError(): void { + $col = $this->makeColumn(5, 'A', 'text'); + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + + $this->expectException(PermissionError::class); + $this->service->apply(1, $this->baseScheme([$this->srcCol(99, 'X', 'text')]), [ + 'columnsDelete' => [999], + ]); + } + + public function testForeignViewTitleInViewsUpdateThrowsNotFoundError(): void { + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); // no views owned + + $this->expectException(\OCA\Tables\Errors\NotFoundError::class); + $this->service->apply(1, $this->baseScheme([], [['title' => 'Ghost', 'filter' => [], 'sort' => []]]), [ + 'viewsUpdate' => ['Ghost' => ['filter']], + ]); + } + + public function testEmptySelectionIsNoOp(): void { + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + $tableScheme = $this->createMock(\OCA\Tables\Model\TableScheme::class); + $tableScheme->method('jsonSerialize')->willReturn(['id' => 1, 'columns' => [], 'views' => []]); + $this->tableService->method('getScheme')->willReturn($tableScheme); + + $result = $this->service->apply(1, $this->baseScheme(), []); + self::assertIsArray($result); + self::assertSame(1, $result['id']); + } + + public function testAddColumnCallsColumnServiceCreate(): void { + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + + $newCol = $this->makeColumn(10, 'Score', 'number'); + $this->columnService->expects(self::once())->method('create')->willReturn($newCol); + + $tableScheme = $this->createMock(\OCA\Tables\Model\TableScheme::class); + $tableScheme->method('jsonSerialize')->willReturn(['id' => 1]); + $this->tableService->method('getScheme')->willReturn($tableScheme); + + $this->service->apply(1, $this->baseScheme([$this->srcCol(5, 'Score', 'number')]), [ + 'columnsAdd' => [5], + ]); + } + + public function testAddViewWithColumnRemapping(): void { + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + + $createdCol = $this->makeColumn(20, 'Score', 'number'); + $this->columnService->method('create')->willReturn($createdCol); + + $createdView = $this->makeView(5, 'Score View'); + $this->viewService->method('create')->willReturn($createdView); + + $capturedUpdate = null; + $this->viewService->method('update')->willReturnCallback(function ($viewId, $updateInput) use (&$capturedUpdate) { + $capturedUpdate = $updateInput; + return $this->makeView(5, 'Score View'); + }); + + $tableScheme = $this->createMock(\OCA\Tables\Model\TableScheme::class); + $tableScheme->method('jsonSerialize')->willReturn(['id' => 1]); + $this->tableService->method('getScheme')->willReturn($tableScheme); + + $srcColId = 7; + $this->service->apply(1, + $this->baseScheme( + [$this->srcCol($srcColId, 'Score', 'number')], + [['title' => 'Score View', 'filter' => [[['columnId' => $srcColId, 'operator' => 'is-not-empty', 'value' => '']]], 'sort' => [], 'columns' => [], 'columnSettings' => []]], + ), + [ + 'columnsAdd' => [$srcColId], + 'viewsAdd' => ['Score View'], + ] + ); + + // The update call should have remapped columnId from srcColId to 20 + self::assertNotNull($capturedUpdate); + } + + public function testViewFilterReferencesUnknownSourceColumnThrows(): void { + $table = $this->makeTable(); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->tableService->method('find')->willReturn($table); + $this->viewService->method('findAll')->willReturn([]); + $this->viewService->method('create')->willReturn($this->makeView(5, 'V')); + + $this->expectException(BadRequestError::class); + $this->service->apply(1, + $this->baseScheme( + [], + [['title' => 'V', 'filter' => [[['columnId' => 999, 'operator' => 'is-not-empty', 'value' => '']]], 'sort' => [], 'columns' => [], 'columnSettings' => []]], + ), + ['viewsAdd' => ['V']] + ); + } + + // Helper to build a scheme with a column so update can find source data + private function makeSchemeForColumnScheme(Column $col): array { + return [ + 'title' => 'T', + 'emoji' => '', + 'description' => '', + 'columns' => [ + ['id' => 99, 'title' => $col->getTitle(), 'type' => $col->getType(), 'description' => 'new desc'], + ], + 'views' => [], + ]; + } +} From 8215e1c3fce16279b8493b30d5728fa5e50fc4ac Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 15:01:14 +0200 Subject: [PATCH 08/15] test(import): add Playwright e2e tests for structure import flow AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../e2e/tables-import-structure.spec.ts | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 playwright/e2e/tables-import-structure.spec.ts diff --git a/playwright/e2e/tables-import-structure.spec.ts b/playwright/e2e/tables-import-structure.spec.ts new file mode 100644 index 0000000000..c3ab40e50d --- /dev/null +++ b/playwright/e2e/tables-import-structure.spec.ts @@ -0,0 +1,285 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { ensureNavigationOpen } from '../support/commands' + +interface StructureScheme { + title: string + columns: Array<{ id: number; title: string; type: string }> + views: Array<{ title: string; filter?: unknown[][]; sort?: unknown[]; columns?: number[]; columnSettings?: unknown[] }> + tablesVersion?: string +} + +/** + * Create a fresh table via the API and return its ID and title. + */ +async function createTableViaApi(page: Page): Promise<{ id: number; title: string }> { + const title = `StructureImportTest-${Date.now()}` + const response = await page.request.post('/index.php/apps/tables/api/2/tables', { + data: { title, emoji: '🗂' }, + }) + expect(response.ok()).toBeTruthy() + const data = await response.json() as { ocs: { data: { id: number; title: string } } } + return data.ocs.data +} + +/** + * Fetch the current scheme of a table via API. + */ +async function getTableSchemeViaApi(page: Page, tableId: number): Promise { + const response = await page.request.get( + `/ocs/v2.php/apps/tables/api/2/tables/${tableId}/scheme`, + { headers: { 'OCS-APIREQUEST': 'true' } }, + ) + expect(response.ok()).toBeTruthy() + const json = await response.json() as { ocs: { data: StructureScheme } } + return json.ocs.data +} + +/** + * Add a column via API. + */ +async function addColumnViaApi(page: Page, tableId: number, title: string, type: string): Promise<{ id: number }> { + const response = await page.request.post( + `/index.php/apps/tables/api/2/tables/${tableId}/columns`, + { data: { title, type } }, + ) + expect(response.ok()).toBeTruthy() + const json = await response.json() as { ocs?: { data: { id: number } }; id?: number } + return json.ocs?.data ?? json as unknown as { id: number } +} + +/** + * Build a minimal scheme JSON with one column and one view (referring to that column). + */ +function buildScheme(baseScheme: StructureScheme, extraColumn: boolean = false): StructureScheme { + const newColumnId = 99999 + const columns = [...baseScheme.columns] + const views = [...baseScheme.views] + + if (extraColumn) { + columns.push({ id: newColumnId, title: 'Imported Column', type: 'text' }) + views.push({ + title: 'Imported View', + filter: [], + sort: [], + columns: [newColumnId], + columnSettings: [], + }) + } + + return { ...baseScheme, columns, views } +} + +/** + * Open the "Import structure" action for a table in the navigation. + * Uploads the given scheme file and waits for the modal to open. + */ +async function openImportStructureModal(page: Page, tableTitle: string, scheme: StructureScheme): Promise { + await ensureNavigationOpen(page) + + const tableItem = page + .locator('[data-cy="navigationTableItem"]') + .filter({ hasText: tableTitle }) + + await tableItem.hover() + const menuButton = tableItem.locator('[aria-haspopup="menu"]').first() + await menuButton.waitFor({ state: 'visible' }) + await menuButton.click({ force: true }) + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('menuitem', { name: /Import structure/i }).click(), + ]) + + await fileChooser.setFiles({ + name: 'structure.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(scheme)), + }) +} + +test.describe('Structure import', () => { + test('Upload valid scheme — diff loads and modal opens', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + await addColumnViaApi(page, table.id, 'Name', 'text') + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithExtra = buildScheme(scheme, true) + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithExtra) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + }) + + test('"Create view" rows start checked, all others unchecked', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + await addColumnViaApi(page, table.id, 'Name', 'text') + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithExtra = buildScheme(scheme, true) + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithExtra) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + // The "Create views" checkbox for "Imported View" should be checked by default + const viewCheckbox = modal.locator('.checkbox-radio-switch').filter({ hasText: 'Imported View' }).first() + await expect(viewCheckbox.locator('input[type="checkbox"]')).toBeChecked() + }) + + test('Submit disabled when nothing checked', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + await addColumnViaApi(page, table.id, 'Name', 'text') + const scheme = await getTableSchemeViaApi(page, table.id) + // Add only a new column (no view) so views-add won't be auto-checked + const schemeWithCol = { ...scheme, columns: [...scheme.columns, { id: 88888, title: 'Extra', type: 'text' }] } + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithCol) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + const submitBtn = modal.getByRole('button', { name: /Apply selected changes/i }) + await expect(submitBtn).toBeDisabled() + }) + + test('Checking any item enables submit', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + await addColumnViaApi(page, table.id, 'Name', 'text') + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithCol = { ...scheme, columns: [...scheme.columns, { id: 88888, title: 'Extra', type: 'text' }] } + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithCol) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + const submitBtn = modal.getByRole('button', { name: /Apply selected changes/i }) + await expect(submitBtn).toBeDisabled() + + // Check the "Extra" column add item + const addCheckbox = modal.locator('.checkbox-radio-switch').filter({ hasText: 'Extra' }).first() + await addCheckbox.locator('input[type="checkbox"]').check() + await expect(submitBtn).toBeEnabled() + }) + + test('Unchecking the only checked item disables submit again', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithExtra = buildScheme(scheme, true) + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithExtra) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + const submitBtn = modal.getByRole('button', { name: /Apply selected changes/i }) + await expect(submitBtn).toBeEnabled() + + // Uncheck the view + const viewCheckbox = modal.locator('.checkbox-radio-switch').filter({ hasText: 'Imported View' }).first() + await viewCheckbox.locator('input[type="checkbox"]').uncheck() + await expect(submitBtn).toBeDisabled() + }) + + test('"Delete column" section is collapsed by default', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + await addColumnViaApi(page, table.id, 'ToDelete', 'text') + const scheme = await getTableSchemeViaApi(page, table.id) + // Remove the column from the scheme so it appears as "delete" in the diff + const schemeWithoutCol = { ...scheme, columns: [] } + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithoutCol) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + // The collapsible delete section button should be visible but its content hidden + const sectionBtn = modal.locator('button').filter({ hasText: /Delete column/i }) + await expect(sectionBtn).toBeVisible() + await expect(sectionBtn).toHaveAttribute('aria-expanded', 'false') + }) + + test('Successful apply → table reloads with new column', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithExtra = { ...scheme, columns: [...scheme.columns, { id: 77777, title: 'NewCol', type: 'text' }] } + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithExtra) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + // Check "NewCol" + const addCheckbox = modal.locator('.checkbox-radio-switch').filter({ hasText: 'NewCol' }).first() + await addCheckbox.locator('input[type="checkbox"]').check() + + const applyReqPromise = page.waitForResponse( + (response) => + response.url().includes('/scheme') + && response.request().method() === 'PUT', + ) + + await modal.getByRole('button', { name: /Apply selected changes/i }).click() + await applyReqPromise + + // Modal should close + await expect(modal).toBeHidden({ timeout: 10000 }) + + // Navigate to table and verify new column header is visible + const tableItem = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: table.title }) + await tableItem.locator('a').first().click() + await expect(page.locator('th').filter({ hasText: 'NewCol' })).toBeVisible({ timeout: 10000 }) + }) + + test('Cancel → modal closes, no apply call made', async ({ userPage: { page } }) => { + const table = await createTableViaApi(page) + const scheme = await getTableSchemeViaApi(page, table.id) + const schemeWithExtra = buildScheme(scheme, true) + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + + await openImportStructureModal(page, table.title, schemeWithExtra) + + const modal = page.getByRole('dialog', { name: /Import structure preview/i }) + await expect(modal).toBeVisible({ timeout: 15000 }) + + let applyCalled = false + page.on('request', (req) => { + if (req.url().includes('/scheme') && req.method() === 'PUT') { + applyCalled = true + } + }) + + await modal.getByRole('button', { name: /Cancel/i }).click() + await expect(modal).toBeHidden({ timeout: 5000 }) + expect(applyCalled).toBe(false) + }) +}) From fe90ed5c0d4768384a1db545e81f38d7960dfc65 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 15:07:43 +0200 Subject: [PATCH 09/15] fix(import): move hidden file input outside actions slot so file picker works The was inside - @@ -142,6 +140,8 @@ + From afdb5c0cc9fa2ba4fc76b80094b0065a96380232 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 15:10:17 +0200 Subject: [PATCH 10/15] fix(import): use string concatenation for OCS URLs instead of unsupported {id} template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateOcsUrl() does not support {id} placeholder substitution — it just prepends the OCS base path. The literal string '{id}' was being included in the URL, causing 404s silently swallowed by the catch blocks. AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- src/modules/modals/ImportStructurePreview.vue | 2 +- src/modules/navigation/partials/NavigationTableItem.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/modals/ImportStructurePreview.vue b/src/modules/modals/ImportStructurePreview.vue index 0d11356efb..0269a76a23 100644 --- a/src/modules/modals/ImportStructurePreview.vue +++ b/src/modules/modals/ImportStructurePreview.vue @@ -411,7 +411,7 @@ export default { async actionApply() { this.applying = true try { - const url = generateOcsUrl('/apps/tables/api/2/tables/{id}/scheme', { id: this.tableId }) + const url = generateOcsUrl('/apps/tables/api/2/tables/' + this.tableId + '/scheme') await axios.put(url, { scheme: this.scheme, selection: this.selection }) await this.loadTablesFromBE() this.$emit('close') diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index 3f2cb69d9a..c900bd2737 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -309,7 +309,7 @@ export default { return } try { - const url = generateOcsUrl('/apps/tables/api/2/tables/{id}/scheme/diff', { id: this.table.id }) + const url = generateOcsUrl('/apps/tables/api/2/tables/' + this.table.id + '/scheme/diff') const response = await axios.post(url, { scheme }) const diff = response.data?.ocs?.data emit('tables:modal:importStructure', { tableId: this.table.id, scheme, diff }) From 66a3bbef51718935dd494c71095679a0790f280d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 19:12:16 +0200 Subject: [PATCH 11/15] fix(import): align diff service output shape with preview modal expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StructureDiffService was returning a flat structure that ImportStructurePreview was unable to render: - tableMeta: was {changes: [{field, current, incoming}]}, now {field: {current, incoming}} - columns add: was {action, sourceId, title, type, details}, now {action, column: } - columns update: was {action, sourceId, targetId, title, type, changes: [{field,...}]}, now {action, targetId, column: {title, type}, changes: {field: {current, incoming}}} - columns delete: was {action, targetId, title, type}, now {action, targetId, column: {title, type}} - views add: was {action, title, details}, now {action, view: } - views update changes: was [{field, current, incoming}], now {field: {current, incoming}} Updated StructureDiffServiceTest assertions to match the corrected shapes. Also removed the :disabled guard that prevented Preview from firing without a selected file — replaced with a showWarning call inside the handler. AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/StructureDiffService.php | 703 ++++++++-------- src/modules/modals/ImportStructure.vue | 211 +++++ src/modules/modals/Modals.vue | 9 + .../partials/NavigationTableItem.vue | 753 +++++++++--------- .../unit/Service/StructureDiffServiceTest.php | 607 +++++++------- 5 files changed, 1235 insertions(+), 1048 deletions(-) create mode 100644 src/modules/modals/ImportStructure.vue diff --git a/lib/Service/StructureDiffService.php b/lib/Service/StructureDiffService.php index bdb51fcc71..ac28fc2535 100644 --- a/lib/Service/StructureDiffService.php +++ b/lib/Service/StructureDiffService.php @@ -1,351 +1,352 @@ -validateSchemeStructure($scheme); - $this->enforceArraySizeLimits($scheme); - - $table = $this->tableService->find($tableId, $this->userId); - $targetColumns = $this->columnService->findAllByTable($tableId, $this->userId); - $targetViews = $this->viewService->findAll($table, $this->userId); - - $diff = []; - - $versionWarning = $this->buildVersionWarning($scheme['tablesVersion'] ?? ''); - if ($versionWarning !== null) { - $diff['versionWarning'] = $versionWarning; - } - - $tableMeta = $this->diffTableMeta($table, $scheme); - if (!empty($tableMeta['changes'])) { - $diff['tableMeta'] = $tableMeta; - } - - [$columnDiff, $columnMap] = $this->diffColumns($targetColumns, $scheme['columns']); - if (!empty($columnDiff)) { - $diff['columns'] = $columnDiff; - } - - $viewDiff = $this->diffViews($targetViews, $scheme['views'] ?? [], $columnMap, $scheme['columns']); - if (!empty($viewDiff)) { - $diff['views'] = $viewDiff; - } - - $diff['columnMap'] = $columnMap; - - return $diff; - } - - /** - * @throws BadRequestError - */ - private function validateSchemeStructure(array $scheme): void { - if (!isset($scheme['columns'], $scheme['views'])) { - throw new BadRequestError('Invalid scheme structure: missing required keys "columns" or "views".'); - } - if (!is_array($scheme['columns'])) { - throw new BadRequestError('Invalid scheme structure: "columns" must be an array.'); - } - if (!is_array($scheme['views'])) { - throw new BadRequestError('Invalid scheme structure: "views" must be an array.'); - } - foreach ($scheme['columns'] as $col) { - if (!is_array($col) - || !isset($col['id'], $col['title'], $col['type']) - || !is_int($col['id']) - || !is_string($col['title']) - || !is_string($col['type']) - ) { - throw new BadRequestError('Invalid scheme structure: each column must be an array with integer "id", string "title" and string "type".'); - } - } - } - - /** - * @throws BadRequestError - */ - private function enforceArraySizeLimits(array $scheme): void { - if (count($scheme['columns']) > self::MAX_COLUMNS) { - throw new BadRequestError('Payload exceeds maximum allowed size: "columns" must not exceed ' . self::MAX_COLUMNS . ' entries.'); - } - if (count($scheme['views'] ?? []) > self::MAX_VIEWS) { - throw new BadRequestError('Payload exceeds maximum allowed size: "views" must not exceed ' . self::MAX_VIEWS . ' entries.'); - } - } - - private function buildVersionWarning(?string $schemeVersion): ?string { - if ($schemeVersion === null || $schemeVersion === '') { - return null; - } - $appVersion = $this->appManager->getAppVersion('tables'); - $schemeMajor = (int)explode('.', $schemeVersion)[0]; - $appMajor = (int)explode('.', $appVersion)[0]; - if ($schemeMajor !== $appMajor) { - return sprintf( - 'Scheme was generated by Tables %s; this instance runs %s. Verify the result carefully.', - $schemeVersion, - $appVersion, - ); - } - return null; - } - - private function diffTableMeta(\OCA\Tables\Db\Table $table, array $scheme): array { - $changes = []; - $fields = ['title', 'emoji', 'description']; - $getters = ['title' => 'getTitle', 'emoji' => 'getEmoji', 'description' => 'getDescription']; - foreach ($fields as $field) { - if (!array_key_exists($field, $scheme)) { - continue; - } - $current = $table->{$getters[$field]}(); - $incoming = $scheme[$field]; - if ($current !== $incoming) { - $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; - } - } - return ['changes' => $changes]; - } - - /** - * @param \OCA\Tables\Db\Column[] $targetColumns - * @param array $sourceColumns - * @return array{0: array, 1: array} - */ - private function diffColumns(array $targetColumns, array $sourceColumns): array { - $columnDiff = []; - $columnMap = []; - - $targetByKey = []; - foreach ($targetColumns as $col) { - $key = strtolower($col->getTitle()) . '|' . $col->getType(); - $targetByKey[$key] = $col; - } - - $matchedTargetIds = []; - - foreach ($sourceColumns as $srcCol) { - $key = strtolower($srcCol['title']) . '|' . $srcCol['type']; - if (isset($targetByKey[$key])) { - $targetCol = $targetByKey[$key]; - $matchedTargetIds[] = $targetCol->getId(); - $columnMap[] = [ - 'sourceName' => $srcCol['title'], - 'sourceType' => $srcCol['type'], - 'sourceId' => $srcCol['id'], - 'targetId' => $targetCol->getId(), - ]; - $changes = $this->diffColumnProperties($targetCol, $srcCol); - if (!empty($changes)) { - $columnDiff[] = [ - 'action' => 'update', - 'sourceId' => $srcCol['id'], - 'targetId' => $targetCol->getId(), - 'title' => $srcCol['title'], - 'type' => $srcCol['type'], - 'changes' => $changes, - ]; - } - } else { - $columnMap[] = [ - 'sourceName' => $srcCol['title'], - 'sourceType' => $srcCol['type'], - 'sourceId' => $srcCol['id'], - 'targetId' => null, - ]; - $columnDiff[] = [ - 'action' => 'add', - 'sourceId' => $srcCol['id'], - 'title' => $srcCol['title'], - 'type' => $srcCol['type'], - 'details' => $srcCol, - ]; - } - } - - foreach ($targetColumns as $targetCol) { - if (!in_array($targetCol->getId(), $matchedTargetIds, true)) { - $columnDiff[] = [ - 'action' => 'delete', - 'targetId' => $targetCol->getId(), - 'title' => $targetCol->getTitle(), - 'type' => $targetCol->getType(), - ]; - } - } - - return [$columnDiff, $columnMap]; - } - - private function diffColumnProperties(\OCA\Tables\Db\Column $targetCol, array $srcCol): array { - $changes = []; - $targetArr = $targetCol->jsonSerialize(); - foreach (self::COLUMN_DIFFABLE_FIELDS as $field) { - $current = $targetArr[$field] ?? null; - $incoming = $srcCol[$field] ?? null; - if ($field === 'selectionOptions' && is_array($incoming)) { - $incoming = json_encode($incoming); - } - if ($current !== $incoming) { - $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; - } - } - return $changes; - } - - /** - * @param \OCA\Tables\Db\View[] $targetViews - * @param array $sourceViews - * @param array $columnMap - * @param array $sourceColumns - * @return array - */ - private function diffViews(array $targetViews, array $sourceViews, array $columnMap, array $sourceColumns): array { - $viewDiff = []; - - $targetByTitle = []; - foreach ($targetViews as $view) { - $targetByTitle[$view->getTitle()] = $view; - } - - $sourceColumnIds = array_column($sourceColumns, 'id'); - $overriddenSelectionColumnIds = $this->findOverriddenSelectionColumnIds($columnMap); - - foreach ($sourceViews as $srcView) { - $title = $srcView['title'] ?? ''; - if (!isset($targetByTitle[$title])) { - $viewDiff[] = [ - 'action' => 'add', - 'title' => $title, - 'details' => $srcView, - ]; - } else { - $targetView = $targetByTitle[$title]; - $changes = $this->diffViewProperties($targetView, $srcView); - if (!empty($changes)) { - $item = [ - 'action' => 'update', - 'title' => $title, - 'changes' => $changes, - ]; - if ($this->hasSelectionFilterWarning($srcView, $overriddenSelectionColumnIds, $sourceColumnIds)) { - $item['selectionFilterWarning'] = true; - } - $viewDiff[] = $item; - } - } - } - - return $viewDiff; - } - - private function diffViewProperties(\OCA\Tables\Db\View $targetView, array $srcView): array { - $changes = []; - $targetArr = $targetView->jsonSerialize(); - foreach (self::VIEW_DIFFABLE_FIELDS as $field) { - $current = $targetArr[$field] ?? null; - $incoming = $srcView[$field] ?? null; - if (is_array($current)) { - $current = json_encode($current); - } - if (is_array($incoming)) { - $incoming = json_encode($incoming); - } - if ($current !== $incoming) { - $changes[] = ['field' => $field, 'current' => $targetArr[$field] ?? null, 'incoming' => $srcView[$field] ?? null]; - } - } - return $changes; - } - - /** - * Returns the set of source column IDs whose selection options are being fully replaced. - * A column is considered "overridden" if it has an update action with a selectionOptions change. - */ - private function findOverriddenSelectionColumnIds(array $columnMap): array { - $overridden = []; - foreach ($columnMap as $entry) { - if ($entry['targetId'] !== null && $entry['sourceType'] === 'selection') { - $overridden[] = $entry['sourceId']; - } - } - return $overridden; - } - - /** - * Returns true when a view's filter JSON contains @selection-id-{id} tokens for columns - * whose selection options are being replaced. - */ - private function hasSelectionFilterWarning(array $srcView, array $overriddenSelectionColumnIds, array $allSourceColumnIds): bool { - if (empty($overriddenSelectionColumnIds)) { - return false; - } - $filterJson = is_array($srcView['filter'] ?? null) - ? json_encode($srcView['filter']) - : ($srcView['filter'] ?? ''); - if (!is_string($filterJson) || $filterJson === '') { - return false; - } - if (preg_match_all('/@selection-id-(\d+)/', $filterJson, $matches)) { - foreach ($overriddenSelectionColumnIds as $id) { - if (in_array((string)$id, $matches[1], true)) { - return true; - } - } - } - return false; - } -} +validateSchemeStructure($scheme); + $this->enforceArraySizeLimits($scheme); + + $table = $this->tableService->find($tableId, $this->userId); + $targetColumns = $this->columnService->findAllByTable($tableId, $this->userId); + $targetViews = $this->viewService->findAll($table, $this->userId); + + $diff = []; + + $versionWarning = $this->buildVersionWarning($scheme['tablesVersion'] ?? ''); + if ($versionWarning !== null) { + $diff['versionWarning'] = $versionWarning; + } + + $tableMeta = $this->diffTableMeta($table, $scheme); + if (!empty($tableMeta)) { + $diff['tableMeta'] = $tableMeta; + } + + [$columnDiff, $columnMap] = $this->diffColumns($targetColumns, $scheme['columns']); + if (!empty($columnDiff)) { + $diff['columns'] = $columnDiff; + } + + $viewDiff = $this->diffViews($targetViews, $scheme['views'] ?? [], $columnMap, $scheme['columns']); + if (!empty($viewDiff)) { + $diff['views'] = $viewDiff; + } + + $diff['columnMap'] = $columnMap; + + return $diff; + } + + /** + * @throws BadRequestError + */ + private function validateSchemeStructure(array $scheme): void { + if (!isset($scheme['columns'], $scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: missing required keys "columns" or "views".'); + } + if (!is_array($scheme['columns'])) { + throw new BadRequestError('Invalid scheme structure: "columns" must be an array.'); + } + if (!is_array($scheme['views'])) { + throw new BadRequestError('Invalid scheme structure: "views" must be an array.'); + } + foreach ($scheme['columns'] as $col) { + if (!is_array($col) + || !isset($col['id'], $col['title'], $col['type']) + || !is_int($col['id']) + || !is_string($col['title']) + || !is_string($col['type']) + ) { + throw new BadRequestError('Invalid scheme structure: each column must be an array with integer "id", string "title" and string "type".'); + } + } + } + + /** + * @throws BadRequestError + */ + private function enforceArraySizeLimits(array $scheme): void { + if (count($scheme['columns']) > self::MAX_COLUMNS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "columns" must not exceed ' . self::MAX_COLUMNS . ' entries.'); + } + if (count($scheme['views'] ?? []) > self::MAX_VIEWS) { + throw new BadRequestError('Payload exceeds maximum allowed size: "views" must not exceed ' . self::MAX_VIEWS . ' entries.'); + } + } + + private function buildVersionWarning(?string $schemeVersion): ?string { + if ($schemeVersion === null || $schemeVersion === '') { + return null; + } + $appVersion = $this->appManager->getAppVersion('tables'); + $schemeMajor = (int)explode('.', $schemeVersion)[0]; + $appMajor = (int)explode('.', $appVersion)[0]; + if ($schemeMajor !== $appMajor) { + return sprintf( + 'Scheme was generated by Tables %s; this instance runs %s. Verify the result carefully.', + $schemeVersion, + $appVersion, + ); + } + return null; + } + + private function diffTableMeta(\OCA\Tables\Db\Table $table, array $scheme): array { + $changes = []; + $fields = ['title', 'emoji', 'description']; + $getters = ['title' => 'getTitle', 'emoji' => 'getEmoji', 'description' => 'getDescription']; + foreach ($fields as $field) { + if (!array_key_exists($field, $scheme)) { + continue; + } + $current = $table->{$getters[$field]}(); + $incoming = $scheme[$field]; + if ($current !== $incoming) { + $changes[$field] = ['current' => $current, 'incoming' => $incoming]; + } + } + return $changes; + } + + /** + * @param \OCA\Tables\Db\Column[] $targetColumns + * @param array $sourceColumns + * @return array{0: array, 1: array} + */ + private function diffColumns(array $targetColumns, array $sourceColumns): array { + $columnDiff = []; + $columnMap = []; + + $targetByKey = []; + foreach ($targetColumns as $col) { + $key = strtolower($col->getTitle()) . '|' . $col->getType(); + $targetByKey[$key] = $col; + } + + $matchedTargetIds = []; + + foreach ($sourceColumns as $srcCol) { + $key = strtolower($srcCol['title']) . '|' . $srcCol['type']; + if (isset($targetByKey[$key])) { + $targetCol = $targetByKey[$key]; + $matchedTargetIds[] = $targetCol->getId(); + $columnMap[] = [ + 'sourceName' => $srcCol['title'], + 'sourceType' => $srcCol['type'], + 'sourceId' => $srcCol['id'], + 'targetId' => $targetCol->getId(), + ]; + $changes = $this->diffColumnProperties($targetCol, $srcCol); + if (!empty($changes)) { + $changesObj = []; + foreach ($changes as $change) { + $changesObj[$change['field']] = ['current' => $change['current'], 'incoming' => $change['incoming']]; + } + $columnDiff[] = [ + 'action' => 'update', + 'targetId' => $targetCol->getId(), + 'column' => ['title' => $srcCol['title'], 'type' => $srcCol['type']], + 'changes' => $changesObj, + ]; + } + } else { + $columnMap[] = [ + 'sourceName' => $srcCol['title'], + 'sourceType' => $srcCol['type'], + 'sourceId' => $srcCol['id'], + 'targetId' => null, + ]; + $columnDiff[] = [ + 'action' => 'add', + 'column' => $srcCol, + ]; + } + } + + foreach ($targetColumns as $targetCol) { + if (!in_array($targetCol->getId(), $matchedTargetIds, true)) { + $columnDiff[] = [ + 'action' => 'delete', + 'targetId' => $targetCol->getId(), + 'column' => ['title' => $targetCol->getTitle(), 'type' => $targetCol->getType()], + ]; + } + } + + return [$columnDiff, $columnMap]; + } + + private function diffColumnProperties(\OCA\Tables\Db\Column $targetCol, array $srcCol): array { + $changes = []; + $targetArr = $targetCol->jsonSerialize(); + foreach (self::COLUMN_DIFFABLE_FIELDS as $field) { + $current = $targetArr[$field] ?? null; + $incoming = $srcCol[$field] ?? null; + if ($field === 'selectionOptions' && is_array($incoming)) { + $incoming = json_encode($incoming); + } + if ($current !== $incoming) { + $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; + } + } + return $changes; + } + + /** + * @param \OCA\Tables\Db\View[] $targetViews + * @param array $sourceViews + * @param array $columnMap + * @param array $sourceColumns + * @return array + */ + private function diffViews(array $targetViews, array $sourceViews, array $columnMap, array $sourceColumns): array { + $viewDiff = []; + + $targetByTitle = []; + foreach ($targetViews as $view) { + $targetByTitle[$view->getTitle()] = $view; + } + + $sourceColumnIds = array_column($sourceColumns, 'id'); + $overriddenSelectionColumnIds = $this->findOverriddenSelectionColumnIds($columnMap); + + foreach ($sourceViews as $srcView) { + $title = $srcView['title'] ?? ''; + if (!isset($targetByTitle[$title])) { + $viewDiff[] = [ + 'action' => 'add', + 'view' => $srcView, + ]; + } else { + $targetView = $targetByTitle[$title]; + $changes = $this->diffViewProperties($targetView, $srcView); + if (!empty($changes)) { + $changesObj = []; + foreach ($changes as $change) { + $changesObj[$change['field']] = ['current' => $change['current'], 'incoming' => $change['incoming']]; + } + $item = [ + 'action' => 'update', + 'title' => $title, + 'changes' => $changesObj, + ]; + if ($this->hasSelectionFilterWarning($srcView, $overriddenSelectionColumnIds, $sourceColumnIds)) { + $item['selectionFilterWarning'] = true; + } + $viewDiff[] = $item; + } + } + } + + return $viewDiff; + } + + private function diffViewProperties(\OCA\Tables\Db\View $targetView, array $srcView): array { + $changes = []; + $targetArr = $targetView->jsonSerialize(); + foreach (self::VIEW_DIFFABLE_FIELDS as $field) { + $current = $targetArr[$field] ?? null; + $incoming = $srcView[$field] ?? null; + if (is_array($current)) { + $current = json_encode($current); + } + if (is_array($incoming)) { + $incoming = json_encode($incoming); + } + if ($current !== $incoming) { + $changes[] = ['field' => $field, 'current' => $targetArr[$field] ?? null, 'incoming' => $srcView[$field] ?? null]; + } + } + return $changes; + } + + /** + * Returns the set of source column IDs whose selection options are being fully replaced. + * A column is considered "overridden" if it has an update action with a selectionOptions change. + */ + private function findOverriddenSelectionColumnIds(array $columnMap): array { + $overridden = []; + foreach ($columnMap as $entry) { + if ($entry['targetId'] !== null && $entry['sourceType'] === 'selection') { + $overridden[] = $entry['sourceId']; + } + } + return $overridden; + } + + /** + * Returns true when a view's filter JSON contains @selection-id-{id} tokens for columns + * whose selection options are being replaced. + */ + private function hasSelectionFilterWarning(array $srcView, array $overriddenSelectionColumnIds, array $allSourceColumnIds): bool { + if (empty($overriddenSelectionColumnIds)) { + return false; + } + $filterJson = is_array($srcView['filter'] ?? null) + ? json_encode($srcView['filter']) + : ($srcView['filter'] ?? ''); + if (!is_string($filterJson) || $filterJson === '') { + return false; + } + if (preg_match_all('/@selection-id-(\d+)/', $filterJson, $matches)) { + foreach ($overriddenSelectionColumnIds as $id) { + if (in_array((string)$id, $matches[1], true)) { + return true; + } + } + } + return false; + } +} diff --git a/src/modules/modals/ImportStructure.vue b/src/modules/modals/ImportStructure.vue new file mode 100644 index 0000000000..1e4bfe30d5 --- /dev/null +++ b/src/modules/modals/ImportStructure.vue @@ -0,0 +1,211 @@ + + + + diff --git a/src/modules/modals/Modals.vue b/src/modules/modals/Modals.vue index 1fc1dce3a5..41c73b8621 100644 --- a/src/modules/modals/Modals.vue +++ b/src/modules/modals/Modals.vue @@ -45,6 +45,10 @@ :show-modal="showImportScheme" :title="importSchemeTitle" @close="showImportScheme = false" /> + { this.importToElement = element }) subscribe('tables:modal:scheme', title => { this.importSchemeTitle = title; this.showImportScheme = true }) subscribe('tables:modal:importStructure', data => { this.importStructureData = data }) + subscribe('tables:modal:importStructure:pick', ({ tableId }) => { this.importStructurePickTableId = tableId }) // context subscribe('tables:context:create', () => { this.showModalCreateContext = true }) @@ -194,6 +202,7 @@ export default { unsubscribe('tables:table:create', () => { this.showModalCreateTable = true }) unsubscribe('tables:modal:import', element => { this.importToElement = element }) unsubscribe('tables:modal:importStructure', data => { this.importStructureData = data }) + unsubscribe('tables:modal:importStructure:pick', ({ tableId }) => { this.importStructurePickTableId = tableId }) unsubscribe('tables:table:delete', table => { this.tableToDelete = table }) unsubscribe('tables:table:edit', tableId => { this.editTable = tableId }) unsubscribe('tables:table:transfer', table => { this.tableToTransfer = table }) diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index c900bd2737..405ada21f9 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -1,393 +1,360 @@ - - - - - + + + + + diff --git a/tests/unit/Service/StructureDiffServiceTest.php b/tests/unit/Service/StructureDiffServiceTest.php index 3415773495..6af18a5318 100644 --- a/tests/unit/Service/StructureDiffServiceTest.php +++ b/tests/unit/Service/StructureDiffServiceTest.php @@ -1,304 +1,303 @@ -createMock(LoggerInterface::class); - $this->permissionsService = $this->createMock(PermissionsService::class); - $this->tableService = $this->createMock(TableService::class); - $this->columnService = $this->createMock(ColumnService::class); - $this->viewService = $this->createMock(ViewService::class); - $this->appManager = $this->createMock(IAppManager::class); - $this->appManager->method('getAppVersion')->willReturn('0.9.0'); - - $this->service = new StructureDiffService( - $logger, - 'admin', - $this->permissionsService, - $this->tableService, - $this->columnService, - $this->viewService, - $this->appManager, - ); - } - - private function makeTable(): Table { - $table = new Table(); - $table->setTitle('Test Table'); - $table->setEmoji(''); - $table->setDescription(''); - return $table; - } - - private function makeColumn(int $id, string $title, string $type): Column { - $col = new Column(); - $col->setId($id); - $col->setTableId(1); - $col->setTitle($title); - $col->setType($type); - $col->setMandatory(false); - $col->setDescription(''); - return $col; - } - - private function makeView(string $title): View { - $view = new View(); - $view->setTableId(1); - $view->setTitle($title); - $view->setEmoji(null); - $view->setDescription(null); - $view->setFilter([]); - $view->setSort([]); - $view->setColumnSettings([]); - return $view; - } - - private function baseScheme(array $columns = [], array $views = [], array $extra = []): array { - return array_merge([ - 'columns' => $columns, - 'views' => $views, - ], $extra); - } - - private function sourceColumn(int $id, string $title, string $type, array $extra = []): array { - return array_merge(['id' => $id, 'title' => $title, 'type' => $type], $extra); - } - - // --- Validation tests --- - - public function testMissingColumnsKeyThrows(): void { - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, ['views' => []]); - } - - public function testMissingViewsKeyThrows(): void { - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, ['columns' => []]); - } - - public function testColumnsNotArrayThrows(): void { - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, ['columns' => 'not-an-array', 'views' => []]); - } - - public function testColumnMissingIdThrows(): void { - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, [ - 'columns' => [['title' => 'X', 'type' => 'text']], - 'views' => [], - ]); - } - - public function testColumnNonIntegerIdThrows(): void { - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, [ - 'columns' => [['id' => '1', 'title' => 'X', 'type' => 'text']], - 'views' => [], - ]); - } - - public function testColumnsCountExceedingLimitThrows(): void { - $columns = []; - for ($i = 1; $i <= 501; $i++) { - $columns[] = ['id' => $i, 'title' => "Col $i", 'type' => 'text']; - } - $this->expectException(BadRequestError::class); - $this->service->computeDiff(1, ['columns' => $columns, 'views' => []]); - } - - // --- Column diff tests --- - - public function testAllColumnsIdenticalProducesNoDiff(): void { - $table = $this->makeTable(); - $col = $this->makeColumn(1, 'Name', 'text'); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([$col]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([ - $this->sourceColumn(1, 'Name', 'text'), - ])); - - self::assertArrayNotHasKey('columns', $result); - } - - public function testNewColumnInSourceProducesAddAction(): void { - $table = $this->makeTable(); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([ - $this->sourceColumn(5, 'Score', 'number'), - ])); - - self::assertCount(1, $result['columns']); - self::assertSame('add', $result['columns'][0]['action']); - self::assertSame('Score', $result['columns'][0]['title']); - } - - public function testMatchedColumnWithPropertyDiffProducesUpdateAction(): void { - $table = $this->makeTable(); - $col = $this->makeColumn(1, 'Name', 'text'); - $col->setDescription('old desc'); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([$col]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([ - $this->sourceColumn(99, 'Name', 'text', ['description' => 'new desc']), - ])); - - self::assertCount(1, $result['columns']); - $colDiff = $result['columns'][0]; - self::assertSame('update', $colDiff['action']); - self::assertSame(1, $colDiff['targetId']); - $fields = array_column($colDiff['changes'], 'field'); - self::assertContains('description', $fields); - } - - public function testTargetOnlyColumnProducesDeleteAction(): void { - $table = $this->makeTable(); - $col = $this->makeColumn(3, 'OldCol', 'text'); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([$col]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([])); - - self::assertCount(1, $result['columns']); - self::assertSame('delete', $result['columns'][0]['action']); - self::assertSame('OldCol', $result['columns'][0]['title']); - } - - public function testSameNameDifferentTypeProducesAddAndDelete(): void { - $table = $this->makeTable(); - $col = $this->makeColumn(1, 'Status', 'text'); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([$col]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([ - $this->sourceColumn(2, 'Status', 'number'), - ])); - - $actions = array_column($result['columns'], 'action'); - self::assertContains('add', $actions); - self::assertContains('delete', $actions); - } - - // --- View diff tests --- - - public function testNewViewTitleInSourceProducesAddAction(): void { - $table = $this->makeTable(); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([], [ - ['title' => 'My View', 'filter' => [], 'sort' => [], 'columns' => [], 'columnSettings' => []], - ])); - - self::assertCount(1, $result['views']); - self::assertSame('add', $result['views'][0]['action']); - self::assertSame('My View', $result['views'][0]['title']); - } - - public function testMatchedViewWithFilterDiffProducesUpdateAction(): void { - $table = $this->makeTable(); - $view = $this->makeView('My View'); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([$view]); - - $result = $this->service->computeDiff(1, $this->baseScheme([], [ - ['title' => 'My View', 'filter' => [['columnId' => 1, 'operator' => 'is-equal', 'value' => 'x']], 'sort' => [], 'columns' => [], 'columnSettings' => []], - ])); - - self::assertCount(1, $result['views']); - self::assertSame('update', $result['views'][0]['action']); - } - - public function testNoDiffAnywherePrducesEmptyDiff(): void { - $table = $this->makeTable(); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([], [])); - - self::assertArrayNotHasKey('columns', $result); - self::assertArrayNotHasKey('views', $result); - self::assertArrayNotHasKey('tableMeta', $result); - self::assertArrayNotHasKey('versionWarning', $result); - } - - public function testVersionMajorMismatchProducesVersionWarning(): void { - $table = $this->makeTable(); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([]); - // appManager is already set to return '0.9.0'; scheme has major 1 - $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '1.0.0'])); - self::assertArrayHasKey('versionWarning', $result); - self::assertStringContainsString('1.0.0', $result['versionWarning']); - } - - public function testSameMajorVersionProducesNoVersionWarning(): void { - $table = $this->makeTable(); - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([]); - $this->viewService->method('findAll')->willReturn([]); - - $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '0.8.5'])); - self::assertArrayNotHasKey('versionWarning', $result); - } - - public function testSelectionColumnOverrideWithSelectionFilterProducesWarning(): void { - $table = $this->makeTable(); - $selCol = $this->makeColumn(10, 'Status', 'selection'); - $selCol->setSelectionOptions('[{"id":1,"label":"A"}]'); - $view = $this->makeView('Filter View'); - $view->setFilter([[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]]); - - $this->tableService->method('find')->willReturn($table); - $this->columnService->method('findAllByTable')->willReturn([$selCol]); - $this->viewService->method('findAll')->willReturn([$view]); - - // Source column same title+type but different options (triggers update), source col ID = 99 - $result = $this->service->computeDiff(1, $this->baseScheme([ - $this->sourceColumn(99, 'Status', 'selection', ['selectionOptions' => [['id' => 2, 'label' => 'B']]]), - ], [ - ['title' => 'Filter View', 'filter' => [[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]], 'sort' => [], 'columns' => [], 'columnSettings' => []], - ])); - - $viewUpdates = array_filter($result['views'] ?? [], fn ($v) => $v['action'] === 'update'); - self::assertNotEmpty($viewUpdates); - $viewUpdate = array_values($viewUpdates)[0]; - self::assertTrue($viewUpdate['selectionFilterWarning'] ?? false); - } -} +createMock(LoggerInterface::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->tableService = $this->createMock(TableService::class); + $this->columnService = $this->createMock(ColumnService::class); + $this->viewService = $this->createMock(ViewService::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppVersion')->willReturn('0.9.0'); + + $this->service = new StructureDiffService( + $logger, + 'admin', + $this->permissionsService, + $this->tableService, + $this->columnService, + $this->viewService, + $this->appManager, + ); + } + + private function makeTable(): Table { + $table = new Table(); + $table->setTitle('Test Table'); + $table->setEmoji(''); + $table->setDescription(''); + return $table; + } + + private function makeColumn(int $id, string $title, string $type): Column { + $col = new Column(); + $col->setId($id); + $col->setTableId(1); + $col->setTitle($title); + $col->setType($type); + $col->setMandatory(false); + $col->setDescription(''); + return $col; + } + + private function makeView(string $title): View { + $view = new View(); + $view->setTableId(1); + $view->setTitle($title); + $view->setEmoji(null); + $view->setDescription(null); + $view->setFilter([]); + $view->setSort([]); + $view->setColumnSettings([]); + return $view; + } + + private function baseScheme(array $columns = [], array $views = [], array $extra = []): array { + return array_merge([ + 'columns' => $columns, + 'views' => $views, + ], $extra); + } + + private function sourceColumn(int $id, string $title, string $type, array $extra = []): array { + return array_merge(['id' => $id, 'title' => $title, 'type' => $type], $extra); + } + + // --- Validation tests --- + + public function testMissingColumnsKeyThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['views' => []]); + } + + public function testMissingViewsKeyThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => []]); + } + + public function testColumnsNotArrayThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => 'not-an-array', 'views' => []]); + } + + public function testColumnMissingIdThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, [ + 'columns' => [['title' => 'X', 'type' => 'text']], + 'views' => [], + ]); + } + + public function testColumnNonIntegerIdThrows(): void { + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, [ + 'columns' => [['id' => '1', 'title' => 'X', 'type' => 'text']], + 'views' => [], + ]); + } + + public function testColumnsCountExceedingLimitThrows(): void { + $columns = []; + for ($i = 1; $i <= 501; $i++) { + $columns[] = ['id' => $i, 'title' => "Col $i", 'type' => 'text']; + } + $this->expectException(BadRequestError::class); + $this->service->computeDiff(1, ['columns' => $columns, 'views' => []]); + } + + // --- Column diff tests --- + + public function testAllColumnsIdenticalProducesNoDiff(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Name', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(1, 'Name', 'text'), + ])); + + self::assertArrayNotHasKey('columns', $result); + } + + public function testNewColumnInSourceProducesAddAction(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(5, 'Score', 'number'), + ])); + + self::assertCount(1, $result['columns']); + self::assertSame('add', $result['columns'][0]['action']); + self::assertSame('Score', $result['columns'][0]['column']['title']); + } + + public function testMatchedColumnWithPropertyDiffProducesUpdateAction(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Name', 'text'); + $col->setDescription('old desc'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(99, 'Name', 'text', ['description' => 'new desc']), + ])); + + self::assertCount(1, $result['columns']); + $colDiff = $result['columns'][0]; + self::assertSame('update', $colDiff['action']); + self::assertSame(1, $colDiff['targetId']); + self::assertArrayHasKey('description', $colDiff['changes']); + } + + public function testTargetOnlyColumnProducesDeleteAction(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(3, 'OldCol', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([])); + + self::assertCount(1, $result['columns']); + self::assertSame('delete', $result['columns'][0]['action']); + self::assertSame('OldCol', $result['columns'][0]['column']['title']); + } + + public function testSameNameDifferentTypeProducesAddAndDelete(): void { + $table = $this->makeTable(); + $col = $this->makeColumn(1, 'Status', 'text'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$col]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(2, 'Status', 'number'), + ])); + + $actions = array_column($result['columns'], 'action'); + self::assertContains('add', $actions); + self::assertContains('delete', $actions); + } + + // --- View diff tests --- + + public function testNewViewTitleInSourceProducesAddAction(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [ + ['title' => 'My View', 'filter' => [], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + self::assertCount(1, $result['views']); + self::assertSame('add', $result['views'][0]['action']); + self::assertSame('My View', $result['views'][0]['view']['title']); + } + + public function testMatchedViewWithFilterDiffProducesUpdateAction(): void { + $table = $this->makeTable(); + $view = $this->makeView('My View'); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([$view]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [ + ['title' => 'My View', 'filter' => [['columnId' => 1, 'operator' => 'is-equal', 'value' => 'x']], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + self::assertCount(1, $result['views']); + self::assertSame('update', $result['views'][0]['action']); + } + + public function testNoDiffAnywherePrducesEmptyDiff(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [])); + + self::assertArrayNotHasKey('columns', $result); + self::assertArrayNotHasKey('views', $result); + self::assertArrayNotHasKey('tableMeta', $result); + self::assertArrayNotHasKey('versionWarning', $result); + } + + public function testVersionMajorMismatchProducesVersionWarning(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + // appManager is already set to return '0.9.0'; scheme has major 1 + $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '1.0.0'])); + self::assertArrayHasKey('versionWarning', $result); + self::assertStringContainsString('1.0.0', $result['versionWarning']); + } + + public function testSameMajorVersionProducesNoVersionWarning(): void { + $table = $this->makeTable(); + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([]); + $this->viewService->method('findAll')->willReturn([]); + + $result = $this->service->computeDiff(1, $this->baseScheme([], [], ['tablesVersion' => '0.8.5'])); + self::assertArrayNotHasKey('versionWarning', $result); + } + + public function testSelectionColumnOverrideWithSelectionFilterProducesWarning(): void { + $table = $this->makeTable(); + $selCol = $this->makeColumn(10, 'Status', 'selection'); + $selCol->setSelectionOptions('[{"id":1,"label":"A"}]'); + $view = $this->makeView('Filter View'); + $view->setFilter([[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]]); + + $this->tableService->method('find')->willReturn($table); + $this->columnService->method('findAllByTable')->willReturn([$selCol]); + $this->viewService->method('findAll')->willReturn([$view]); + + // Source column same title+type but different options (triggers update), source col ID = 99 + $result = $this->service->computeDiff(1, $this->baseScheme([ + $this->sourceColumn(99, 'Status', 'selection', ['selectionOptions' => [['id' => 2, 'label' => 'B']]]), + ], [ + ['title' => 'Filter View', 'filter' => [[['columnId' => 99, 'operator' => 'is-equal', 'value' => '@selection-id-1']]], 'sort' => [], 'columns' => [], 'columnSettings' => []], + ])); + + $viewUpdates = array_filter($result['views'] ?? [], fn ($v) => $v['action'] === 'update'); + self::assertNotEmpty($viewUpdates); + $viewUpdate = array_values($viewUpdates)[0]; + self::assertTrue($viewUpdate['selectionFilterWarning'] ?? false); + } +} From 4ffae231f2cc0268def814b416cba0bb77810aee Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 20 Apr 2026 20:34:22 +0200 Subject: [PATCH 12/15] fix(import): improve column matching for importing and general diff-logic AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/ApplySchemeService.php | 4 +- lib/Service/StructureDiffService.php | 91 +++++++- src/modules/modals/ImportStructurePreview.vue | 210 ++++++++++++++---- .../unit/Service/StructureDiffServiceTest.php | 34 +++ 4 files changed, 290 insertions(+), 49 deletions(-) diff --git a/lib/Service/ApplySchemeService.php b/lib/Service/ApplySchemeService.php index 5a9dc7eec9..9184855eb1 100644 --- a/lib/Service/ApplySchemeService.php +++ b/lib/Service/ApplySchemeService.php @@ -196,8 +196,10 @@ public function apply(int $tableId, array $scheme, array $selection): array { } $srcView = $sourceViewsByTitle[$viewTitle]; $failedStep = "update view '{$viewTitle}'"; + // Remap source column IDs to target IDs before applying + $remappedView = $this->remapViewColumnIds($srcView, $combinedColMap, $viewTitle); // Only apply selected fields - $updatePayload = $this->buildPartialViewPayload($srcView, $fields); + $updatePayload = $this->buildPartialViewPayload($remappedView, $fields); $this->viewService->update($view->getId(), ViewUpdateInput::fromInputArray($updatePayload), $this->userId); } diff --git a/lib/Service/StructureDiffService.php b/lib/Service/StructureDiffService.php index ac28fc2535..12292d060e 100644 --- a/lib/Service/StructureDiffService.php +++ b/lib/Service/StructureDiffService.php @@ -234,16 +234,33 @@ private function diffColumnProperties(\OCA\Tables\Db\Column $targetCol, array $s foreach (self::COLUMN_DIFFABLE_FIELDS as $field) { $current = $targetArr[$field] ?? null; $incoming = $srcCol[$field] ?? null; - if ($field === 'selectionOptions' && is_array($incoming)) { - $incoming = json_encode($incoming); - } - if ($current !== $incoming) { + $currentNorm = $this->normalizeForComparison($current); + $incomingNorm = $this->normalizeForComparison($incoming); + if ($currentNorm !== $incomingNorm) { $changes[] = ['field' => $field, 'current' => $current, 'incoming' => $incoming]; } } return $changes; } + /** + * Normalize a value for structural comparison. + * - false and null are treated as equivalent: boolean fields default to false in the DB + * but are absent (null) when omitted from an exported scheme, so they must not + * trigger a spurious "false → (none)" diff entry. + * - Arrays and objects (including \stdClass) are JSON-encoded so that e.g. an empty + * \stdClass and an empty array compare as equal. + */ + private function normalizeForComparison(mixed $value): mixed { + if ($value === false) { + return null; + } + if (!is_array($value) && !is_object($value)) { + return $value; + } + return json_encode(json_decode(json_encode($value), true)); + } + /** * @param \OCA\Tables\Db\View[] $targetViews * @param array $sourceViews @@ -271,7 +288,10 @@ private function diffViews(array $targetViews, array $sourceViews, array $column ]; } else { $targetView = $targetByTitle[$title]; - $changes = $this->diffViewProperties($targetView, $srcView); + // Remap source column IDs to target IDs for comparison only; + // the original $srcView values are used in the diff output for display. + $srcViewRemapped = $this->remapIncomingViewForComparison($srcView, $columnMap); + $changes = $this->diffViewProperties($targetView, $srcViewRemapped, $srcView); if (!empty($changes)) { $changesObj = []; foreach ($changes as $change) { @@ -293,12 +313,16 @@ private function diffViews(array $targetViews, array $sourceViews, array $column return $viewDiff; } - private function diffViewProperties(\OCA\Tables\Db\View $targetView, array $srcView): array { + private function diffViewProperties( + \OCA\Tables\Db\View $targetView, + array $srcViewForComparison, + array $srcViewOriginal, + ): array { $changes = []; $targetArr = $targetView->jsonSerialize(); foreach (self::VIEW_DIFFABLE_FIELDS as $field) { $current = $targetArr[$field] ?? null; - $incoming = $srcView[$field] ?? null; + $incoming = $srcViewForComparison[$field] ?? null; if (is_array($current)) { $current = json_encode($current); } @@ -306,12 +330,63 @@ private function diffViewProperties(\OCA\Tables\Db\View $targetView, array $srcV $incoming = json_encode($incoming); } if ($current !== $incoming) { - $changes[] = ['field' => $field, 'current' => $targetArr[$field] ?? null, 'incoming' => $srcView[$field] ?? null]; + // Use original (non-remapped) incoming value in the diff output so + // the frontend can resolve source column names for display. + $changes[] = ['field' => $field, 'current' => $targetArr[$field] ?? null, 'incoming' => $srcViewOriginal[$field] ?? null]; } } return $changes; } + /** + * Remap source column IDs to target IDs in a view's structural fields for comparison. + * Source IDs without a matching target are left unchanged so they still produce a diff. + */ + private function remapIncomingViewForComparison(array $srcView, array $columnMap): array { + $idMap = []; + foreach ($columnMap as $entry) { + if ($entry['targetId'] !== null) { + $idMap[(int)$entry['sourceId']] = $entry['targetId']; + } + } + + $remapped = $srcView; + + if (isset($remapped['filter']) && is_array($remapped['filter'])) { + foreach ($remapped['filter'] as &$group) { + foreach ($group as &$cond) { + if (isset($cond['columnId'])) { + $cond['columnId'] = $idMap[(int)$cond['columnId']] ?? $cond['columnId']; + } + } + } + } + + if (isset($remapped['sort']) && is_array($remapped['sort'])) { + foreach ($remapped['sort'] as &$rule) { + if (isset($rule['columnId'])) { + $rule['columnId'] = $idMap[(int)$rule['columnId']] ?? $rule['columnId']; + } + } + } + + if (isset($remapped['columns']) && is_array($remapped['columns'])) { + foreach ($remapped['columns'] as &$id) { + $id = $idMap[(int)$id] ?? $id; + } + } + + if (isset($remapped['columnSettings']) && is_array($remapped['columnSettings'])) { + foreach ($remapped['columnSettings'] as &$cs) { + if (isset($cs['columnId'])) { + $cs['columnId'] = $idMap[(int)$cs['columnId']] ?? $cs['columnId']; + } + } + } + + return $remapped; + } + /** * Returns the set of source column IDs whose selection options are being fully replaced. * A column is considered "overridden" if it has an update action with a selectionOptions change. diff --git a/src/modules/modals/ImportStructurePreview.vue b/src/modules/modals/ImportStructurePreview.vue index 0269a76a23..a2089838dc 100644 --- a/src/modules/modals/ImportStructurePreview.vue +++ b/src/modules/modals/ImportStructurePreview.vue @@ -13,18 +13,24 @@