Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 54 additions & 50 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
use OCA\Tables\Db\PageContent;
use OCA\Tables\Db\PageContentMapper;
use OCA\Tables\Db\PageMapper;
use OCA\Tables\Db\Table;
use OCA\Tables\Db\TableMapper;
use OCA\Tables\Db\ViewMapper;
use OCA\Tables\Errors\BadRequestError;
Expand Down Expand Up @@ -656,65 +655,48 @@ private function importablePages(array $pages): array {
}

/**
* Imports an application of a user's data that holds the given table. Its menu keeps
* the links, the items for its pages and the items for the table, found by the uuid
* it was exported with.
* Imports an exported application with each node pointing at the table or view it was
* imported as. A node that was not imported is left out, together with the page
* contents and widget sources showing it. An application none of whose nodes was
* imported is not imported.
*
* @param Table $table
* @param array $context
* @param int $tableOldId
* @param string|null $tableOldUuid
*
* @return Context|null
* @param array<array-key, mixed> $context exported application
* @param array<int, int> $tableIds new table ids by exported id
* @param array<int, int> $viewIds new view ids by exported id
*
* @throws \Exception
*/
public function importContext(Table $table, array $context, int $tableOldId, ?string $tableOldUuid = null): ?Context {
// checking if any node in $context['nodes'] matches the old table id
$hasMatchingNode = false;
if (!empty($context['nodes'])) {
foreach ($context['nodes'] as $nodeArr) {
if (($nodeArr['node_id'] ?? null) == $tableOldId) {
$hasMatchingNode = true;
break;
}
public function importContext(array $context, string $ownerId, array $tableIds, array $viewIds): ?Context {
$nodes = [];
foreach (is_array($context['nodes'] ?? null) ? $context['nodes'] : [] as $nodeArr) {
$node = is_array($nodeArr) ? self::importedNode($nodeArr, $tableIds, $viewIds) : null;
if ($node !== null) {
$nodes[] = [$nodeArr, ...$node];
}
}

if (!$hasMatchingNode) {
if ($nodes === []) {
return null;
}

$newContext = new Context();
$newContext->setName($context['name'] ?? '');
$newContext->setIcon($context['iconName'] ?? '');
$newContext->setDescription($context['description'] ?? '');
$newContext->setOwnerId($table->getOwnership() ?? '');
$newContext->setOwnerId($ownerId);
$newContext->setOwnerType($context['ownerType'] ?? 0);
$this->contextMapper->insert($newContext);

// insert nodes
$nodeRelMap = [];
// the relations of the table being imported, the only node a widget source can keep
$sourceRelMap = [];
if (!empty($context['nodes'])) {
foreach ($context['nodes'] as $nodeArr) {
$nodeType = $nodeArr['node_type'] ?? $nodeArr['nodeType'] ?? 0;
$contextNodeRel = new ContextNodeRelation();
$contextNodeRel->setContextId($newContext->getId());
$contextNodeRel->setNodeId($table->getId());
$contextNodeRel->setNodeType($nodeType);
$contextNodeRel->setPermissions($nodeArr['permissions'] ?? 0);
$contextNodeRel = $this->contextNodeRelMapper->insert($contextNodeRel);
$oldRelId = self::importedKey($nodeArr['id'] ?? null);
if ($oldRelId === null) {
continue;
}
foreach ($nodes as [$nodeArr, $nodeType, $nodeId]) {
$contextNodeRel = new ContextNodeRelation();
$contextNodeRel->setContextId($newContext->getId());
$contextNodeRel->setNodeId($nodeId);
$contextNodeRel->setNodeType($nodeType);
$contextNodeRel->setPermissions($nodeArr['permissions'] ?? 0);
$contextNodeRel = $this->contextNodeRelMapper->insert($contextNodeRel);
$oldRelId = self::importedKey($nodeArr['id'] ?? null);
if ($oldRelId !== null) {
$nodeRelMap[$oldRelId] = $contextNodeRel;
$oldNodeId = $nodeArr['node_id'] ?? $nodeArr['nodeId'] ?? null;
if (is_numeric($nodeType) && (int)$nodeType === Application::NODE_TYPE_TABLE && is_numeric($oldNodeId) && (int)$oldNodeId === $tableOldId) {
$sourceRelMap[$oldRelId] = $contextNodeRel;
}
}
}

Expand Down Expand Up @@ -759,23 +741,45 @@ public function importContext(Table $table, array $context, int $tableOldId, ?st
$this->logger->warning('Skipping ' . (count($widgets) - GridWidgetTypes::MAX_WIDGETS_PER_PAGE) . ' imported widgets: a page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . '.');
}
foreach (array_slice($widgets, 0, GridWidgetTypes::MAX_WIDGETS_PER_PAGE) as $index => $widgetArr) {
$this->importWidget($page->getId(), $index, $widgetArr, $sourceRelMap);
$this->importWidget($page->getId(), $index, $widgetArr, $nodeRelMap);
}
}
}

if (is_array($context['menu'] ?? null)) {
$tableRelation = reset($sourceRelMap);
$imported = $this->contextMapper->findById($newContext->getId());
$this->logSkippedMenuItems($this->menuService->import(
$this->contextMapper->findById($newContext->getId()),
$imported,
$context['menu'],
static fn (string $kind, string $uuid): ?int => $kind === 'table' && $uuid === $tableOldUuid && $tableRelation !== false ? $tableRelation->getId() : null,
fn (string $kind, string $uuid): ?int => $this->nodeRelIdByUuid($imported, $kind, $uuid),
));
}

return $newContext;
}

/**
* The node type and new id of an exported node, or null when it was not imported.
*
* @param array<array-key, mixed> $nodeArr
* @param array<int, int> $tableIds new table ids by exported id
* @param array<int, int> $viewIds new view ids by exported id
* @return array{0: int, 1: int}|null
*/
private static function importedNode(array $nodeArr, array $tableIds, array $viewIds): ?array {
$nodeType = $nodeArr['node_type'] ?? $nodeArr['nodeType'] ?? null;
$oldNodeId = $nodeArr['node_id'] ?? $nodeArr['nodeId'] ?? null;
if (!is_int($oldNodeId)) {
return null;
}
$nodeId = match ($nodeType) {
Application::NODE_TYPE_TABLE => $tableIds[$oldNodeId] ?? null,
Application::NODE_TYPE_VIEW => $viewIds[$oldNodeId] ?? null,
default => null,
};
return $nodeId === null ? null : [$nodeType, $nodeId];
}

/**
* @param list<array{index: int, title: ?string, reason: string}> $skipped
*/
Expand Down Expand Up @@ -852,11 +856,11 @@ public function getPagesWithWidgets(Context $context): array {

/**
* Inserts an exported widget on a page. Its source is remapped to the imported node
* relation of the table being imported, and dropped when it showed another node. A
* widget that would not be saved through the API, for its type, settings, title,
* description, place on the grid or the shape of its source, is skipped.
* relation of its node, and dropped when that node was not imported. A widget that
* would not be saved through the API, for its type, settings, title, description,
* place on the grid or the shape of its source, is skipped.
*
* @param array<array-key, ContextNodeRelation> $nodeRelMap imported node relations of the table by their exported id
* @param array<array-key, ContextNodeRelation> $nodeRelMap imported node relations by their exported id
* @throws Exception
*/
private function importWidget(int $pageId, int $index, mixed $widgetArr, array $nodeRelMap): void {
Expand Down
29 changes: 16 additions & 13 deletions lib/UserMigration/TablesMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ public function import(
$contexts = json_decode($importSource->getFileContents(self::FILE_CONTEXTS), true, self::JSON_DEPTH, self::JSON_OPTIONS);

$tableIdMap = [];
$contextIdMap = [];
$columnIdMap = [];
$rowIdMap = [];
$userId = $user->getUID();
Expand Down Expand Up @@ -236,12 +235,11 @@ public function import(
}

$rowIdMap = $this->importRows($importSource, $newTable, $table, $rowIdMap);

$this->importContexts($contexts, $newTable, $table, $contextIdMap);
$tableIdMap[$table['id']] = $newTable->getId();
}

$this->importViews($importSource, $tableIdMap, $columnIdMap, $userId);
$viewIdMap = $this->importViews($importSource, $tableIdMap, $columnIdMap, $userId);
$contextIdMap = $this->importContexts($contexts, $userId, $tableIdMap, $viewIdMap);
$this->importShares($importSource, $tableIdMap, $contextIdMap, $userId);

$this->importRowCells(
Expand Down Expand Up @@ -351,10 +349,11 @@ private function importRowCells(array $data, array $rowIdMap, array $columnIdMap
* @param array $columnIdMap
* @param string $userId
*
* @return void
* @return array<int, int> new view ids by exported id
*/
private function importViews(IImportSource $importSource, array $tableIdMap, array $columnIdMap, string $userId): void {
private function importViews(IImportSource $importSource, array $tableIdMap, array $columnIdMap, string $userId): array {
$views = json_decode($importSource->getFileContents(self::FILE_VIEWS), true, self::JSON_DEPTH, self::JSON_OPTIONS);
$viewIdMap = [];
foreach ($views as $view) {
if (isset($tableIdMap[$view['tableId']])) {
$newTableId = $tableIdMap[$view['tableId']];
Expand All @@ -367,9 +366,10 @@ private function importViews(IImportSource $importSource, array $tableIdMap, arr
unset($setting);
}
$view = $this->remapViewCardSources($view, $columnIdMap);
$this->viewService->importView($newTableId, $view, $userId);
$viewIdMap[$view['id']] = $this->viewService->importView($newTableId, $view, $userId)->getId();
}
}
return $viewIdMap;
}

private function remapViewCardSources(array $view, array $columnIdMap): array {
Expand Down Expand Up @@ -437,20 +437,23 @@ private function importRows(IImportSource $importSource, Table $newTable, array
}

/**
* Imports each application once, with its nodes pointing at the imported tables and views.
*
* @param array $contexts
* @param Table $newTable
* @param array $table
* @param array $contextIdMap
* @param array<int, int> $tableIdMap new table ids by exported id
* @param array<int, int> $viewIdMap new view ids by exported id
*
* @return void
* @return array<int, int> new application ids by exported id
*/
private function importContexts(array $contexts, Table $newTable, array $table, array &$contextIdMap): void {
private function importContexts(array $contexts, string $userId, array $tableIdMap, array $viewIdMap): array {
$contextIdMap = [];
foreach ($contexts as $context) {
$newContext = $this->contextService->importContext($newTable, $context, $table['id'], $table['uuid'] ?? null);
$newContext = $this->contextService->importContext($context, $userId, $tableIdMap, $viewIdMap);
if ($newContext !== null) {
$contextIdMap[$context['id']] = $newContext->getId();
}
}
return $contextIdMap;
}

/**
Expand Down
62 changes: 52 additions & 10 deletions tests/unit/Service/ContextPagesRoundTripTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use OCP\INavigationManager;
use OCP\IURLGenerator;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;

/**
Expand All @@ -51,6 +52,8 @@ class ContextPagesRoundTripTest extends DatabaseTestCase {
private ContextNodeRelationMapper $nodeRelMapper;
private PageMapper $pageMapper;
private WidgetService $widgetService;
private PermissionsService&MockObject $permissionsService;
private TableMapper&MockObject $tableMapper;
/** @var int[] */
private array $contextIds = [];

Expand All @@ -68,21 +71,23 @@ protected function setUp(): void {
new WidgetSourceMapper($this->connectionAdapter),
$this->connectionAdapter,
);
$this->permissionsService = $this->createMock(PermissionsService::class);
$this->tableMapper = $this->createMock(TableMapper::class);
$this->service = new ContextService(
$this->contextMapper,
$this->nodeRelMapper,
$this->pageMapper,
new PageContentMapper($this->connectionAdapter),
$this->createMock(LoggerInterface::class),
$this->createMock(PermissionsService::class),
$this->permissionsService,
$this->createMock(IUserManager::class),
$this->createMock(AuditLogServiceInterface::class),
$this->connectionAdapter,
$this->createMock(ShareService::class),
false,
$this->createMock(INavigationManager::class),
$this->createMock(IURLGenerator::class),
$this->createMock(TableMapper::class),
$this->tableMapper,
$this->createMock(ViewMapper::class),
$this->widgetService,
new GridWidgetTypes($l10n),
Expand All @@ -103,17 +108,14 @@ protected function tearDown(): void {

public function testExportedWidgetsAreImportedWithTheirNode(): void {
[$context, $relation, $gridPageId] = $this->createContextWithGridPage();
$table = new Table();
$table->id = 4242;
$table->setOwnership(self::OWNER);

$exported = json_decode(json_encode(array_merge(
$this->contextMapper->findById($context->getId(), self::OWNER)->jsonSerialize(),
['pages' => $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))],
)), true);
$exported['nodes'][$relation->getId()]['node_id'] = 4242;

$imported = $this->service->importContext($table, $exported, 4242);
$imported = $this->service->importContext($exported, self::OWNER, [4242 => 4242], []);
$this->assertNotNull($imported);
$this->contextIds[] = $imported->getId();

Expand All @@ -139,9 +141,6 @@ public function testExportedWidgetsAreImportedWithTheirNode(): void {

public function testAnUnknownIconIsDroppedOnImport(): void {
[$context, $relation, $gridPageId] = $this->createContextWithGridPage();
$table = new Table();
$table->id = 4243;
$table->setOwnership(self::OWNER);
$exported = json_decode(json_encode(array_merge(
$this->contextMapper->findById($context->getId(), self::OWNER)->jsonSerialize(),
['pages' => $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))],
Expand All @@ -150,7 +149,7 @@ public function testAnUnknownIconIsDroppedOnImport(): void {
$exported['pages'][$gridPageId]['icon'] = 'no-such-icon';
$exported['pages'][$gridPageId]['widgets'][0]['icon'] = '../star';

$imported = $this->service->importContext($table, $exported, 4243);
$imported = $this->service->importContext($exported, self::OWNER, [4243 => 4243], []);
$this->assertNotNull($imported);
$this->contextIds[] = $imported->getId();

Expand All @@ -177,6 +176,49 @@ public function testRemovingTheNodeKeepsTheWidgetAndDeletingTheContextRemovesIt(
$this->assertSame([], $this->pageMapper->getPageIdsForContext($context->getId()));
}

public function testAnApplicationOfTwoTablesAndAViewIsImportedWithEachNodeOnItsOwnTarget(): void {
$this->permissionsService->method('canManageNodeById')->willReturn(true);
$this->tableMapper->method('findMany')->willReturnCallback(static fn (array $ids): array => array_combine($ids, array_map(static fn (): Table => new Table(), $ids)));
$node = static fn (int $id, int $type): array => ['id' => $id, 'type' => $type, 'permissions' => Application::PERMISSION_READ];
$context = $this->service->create('Two tables', 'tables', '', [
$node(4151, Application::NODE_TYPE_TABLE),
$node(4152, Application::NODE_TYPE_TABLE),
$node(4153, Application::NODE_TYPE_VIEW),
], self::OWNER, Application::OWNER_TYPE_USER);
$this->contextIds[] = $context->getId();
$stored = $this->contextMapper->findById($context->getId(), self::OWNER);
$relationIds = array_column($stored->getNodes(), 'id', 'node_id');
$page = new Page();
$page->setContextId($context->getId());
$page->setPageType(Page::TYPE_GRID);
$page->setTechnicalName('overview');
$page = $this->pageMapper->insert($page);
foreach ([4151, 4152, 4153] as $y => $nodeId) {
$this->widgetService->insert($page->getId(), 'data', 'Node ' . $nodeId, ['gridX' => 0, 'gridY' => $y * 5, 'gridWidth' => 12, 'gridHeight' => 5], [], $relationIds[$nodeId]);
}
$stored = $this->contextMapper->findById($context->getId(), self::OWNER);
$exported = json_decode(json_encode(array_merge($stored->jsonSerialize(), ['pages' => $this->service->getPagesWithWidgets($stored)])), true);

$imported = $this->service->importContext($exported, self::OWNER, [4151 => 5151, 4152 => 5152], [4153 => 5153]);
$this->assertNotNull($imported);
$this->contextIds[] = $imported->getId();

$importedContext = $this->contextMapper->findById($imported->getId(), self::OWNER);
$nodes = array_values($importedContext->getNodes());
$this->assertSame(
[[5151, Application::NODE_TYPE_TABLE], [5152, Application::NODE_TYPE_TABLE], [5153, Application::NODE_TYPE_VIEW]],
array_map(static fn (array $node): array => [$node['node_id'], $node['node_type']], $nodes),
);
$importedRelationIds = array_column($nodes, 'id', 'node_id');
$startPage = array_values(array_filter($importedContext->getPages(), static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE))[0];
$this->assertEqualsCanonicalizing(array_values($importedRelationIds), array_column($startPage['content'], 'node_rel_id'));
$gridPage = array_values(array_filter($this->service->getPagesWithWidgets($importedContext), static fn (array $page): bool => $page['page_type'] === Page::TYPE_GRID))[0];
$this->assertSame(
['Node 4151' => $importedRelationIds[5151], 'Node 4152' => $importedRelationIds[5152], 'Node 4153' => $importedRelationIds[5153]],
array_combine(array_column($gridPage['widgets'], 'title'), array_map(static fn (array $widget): ?int => $widget['source']['nodeRelId'] ?? null, $gridPage['widgets'])),
);
}

/**
* @return array{0: Context, 1: ContextNodeRelation, 2: int}
*/
Expand Down
Loading
Loading