Skip to content

Commit 4e518cf

Browse files
authored
Merge pull request #5 from LibreCodeCoop/feat/f1-import-contract
feat: add versioned import contract foundation
2 parents c616237 + 3909236 commit 4e518cf

34 files changed

+3951
-87
lines changed

.github/workflows/openapi.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ jobs:
7878
- name: Regenerate OpenAPI
7979
run: composer run openapi
8080

81+
- name: Regenerate TypeScript OpenAPI types
82+
if: steps.check_typescript_openapi.outputs.files_exists == 'true'
83+
run: npm run typescript:generate
84+
8185
- name: Check openapi*.json and typescript changes
8286
run: |
8387
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and, if applicable, src/types/openapi/openapi*.ts. See the diff below.' && exit 1)"

REUSE.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ path = [
2727
"package-lock.json",
2828
"package.json",
2929
"psalm.xml",
30+
"src/types/openapi/*.ts",
3031
"tests/integration/composer.json",
3132
"tests/integration/composer.lock",
3233
"tests/integration/features/**/*.feature",

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
4949
</repair-steps>
5050
<commands>
5151
<command>OCA\ProfileFields\Command\Data\Export</command>
52+
<command>OCA\ProfileFields\Command\Data\Import</command>
5253
<command>OCA\ProfileFields\Command\Data\Clear</command>
5354
<command>OCA\ProfileFields\Command\Developer\Reset</command>
5455
</commands>

lib/AppInfo/Application.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,35 @@ public function register(IRegistrationContext $context): void {
3838

3939
#[\Override]
4040
public function boot(IBootContext $context): void {
41-
$request = $context->getServerContainer()->get(IRequest::class);
42-
$path = $request->getPathInfo();
43-
$requestUri = $request->getRequestUri();
41+
try {
42+
$context->injectFn($this->bootWithRequest(...));
43+
} catch (\Throwable) {
44+
return;
45+
}
46+
}
47+
48+
private function bootWithRequest(IRequest $request): void {
49+
$path = $this->readRequestString(static fn (): string|false => $request->getPathInfo());
50+
$requestUri = $this->readRequestString(static fn (): string => $request->getRequestUri());
4451

4552
if (
46-
($path !== false && str_contains($path, '/settings/users'))
47-
|| str_contains($requestUri, '/settings/users')
53+
($path !== null && str_contains($path, '/settings/users'))
54+
|| ($requestUri !== null && str_contains($requestUri, '/settings/users'))
4855
) {
4956
self::loadUserManagementAssets();
5057
}
5158
}
5259

60+
private function readRequestString(callable $reader): ?string {
61+
try {
62+
$value = $reader();
63+
} catch (\Throwable) {
64+
return null;
65+
}
66+
67+
return is_string($value) && $value !== '' ? $value : null;
68+
}
69+
5370
public static function loadUserManagementAssets(): void {
5471
if (self::$userManagementAssetsLoaded) {
5572
return;

lib/Command/Data/Export.php

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use Symfony\Component\Console\Output\OutputInterface;
2020

2121
class Export extends Command {
22+
private const SCHEMA_VERSION = 1;
23+
2224
public function __construct(
2325
private FieldDefinitionService $fieldDefinitionService,
2426
private FieldValueMapper $fieldValueMapper,
@@ -47,17 +49,30 @@ protected function configure(): void {
4749

4850
#[\Override]
4951
protected function execute(InputInterface $input, OutputInterface $output): int {
50-
$payload = [
51-
'exported_at' => gmdate(DATE_ATOM),
52-
'definitions' => array_map(
53-
static fn ($definition): array => $definition->jsonSerialize(),
54-
$this->fieldDefinitionService->findAllOrdered(),
55-
),
56-
'values' => array_map(
57-
fn (FieldValue $value): array => $this->serializeValue($value),
58-
$this->fieldValueMapper->findAllOrdered(),
59-
),
60-
];
52+
try {
53+
$definitions = $this->fieldDefinitionService->findAllOrdered();
54+
$fieldKeysByDefinitionId = [];
55+
foreach ($definitions as $definition) {
56+
$fieldKeysByDefinitionId[$definition->getId()] = $definition->getFieldKey();
57+
}
58+
59+
$payload = [
60+
'schema_version' => self::SCHEMA_VERSION,
61+
'exported_at' => gmdate(DATE_ATOM),
62+
'definitions' => array_map(
63+
static fn ($definition): array => $definition->jsonSerialize(),
64+
$definitions,
65+
),
66+
'values' => array_map(
67+
fn (FieldValue $value): array => $this->serializeValue($value, $fieldKeysByDefinitionId),
68+
$this->fieldValueMapper->findAllOrdered(),
69+
),
70+
];
71+
} catch (\Throwable $exception) {
72+
$output->writeln('<error>Failed to build export payload.</error>');
73+
$output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));
74+
return self::FAILURE;
75+
}
6176

6277
try {
6378
$json = json_encode(
@@ -88,16 +103,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
88103
/**
89104
* @return array<string, mixed>
90105
*/
91-
private function serializeValue(FieldValue $value): array {
106+
private function serializeValue(FieldValue $value, array $fieldKeysByDefinitionId): array {
92107
try {
93108
$decodedValue = json_decode($value->getValueJson(), true, 512, JSON_THROW_ON_ERROR);
94109
} catch (JsonException $exception) {
95110
throw new \RuntimeException('Failed to decode stored field value JSON.', 0, $exception);
96111
}
97112

113+
$fieldKey = $fieldKeysByDefinitionId[$value->getFieldDefinitionId()] ?? null;
114+
if (!is_string($fieldKey) || $fieldKey === '') {
115+
throw new \RuntimeException(sprintf('Could not resolve field_key for field definition %d.', $value->getFieldDefinitionId()));
116+
}
117+
98118
return [
99119
'id' => $value->getId(),
100120
'field_definition_id' => $value->getFieldDefinitionId(),
121+
'field_key' => $fieldKey,
101122
'user_uid' => $value->getUserUid(),
102123
'value' => $decodedValue,
103124
'current_visibility' => $value->getCurrentVisibility(),

lib/Command/Data/Import.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OCA\ProfileFields\Command\Data;
11+
12+
use JsonException;
13+
use OCA\ProfileFields\Service\DataImportService;
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Input\InputOption;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
19+
class Import extends Command {
20+
public function __construct(
21+
private DataImportService $dataImportService,
22+
) {
23+
parent::__construct();
24+
}
25+
26+
#[\Override]
27+
protected function configure(): void {
28+
$this
29+
->setName('profile_fields:data:import')
30+
->setDescription('Import persisted Profile Fields definitions and values from a JSON payload')
31+
->addOption(
32+
name: 'input',
33+
shortcut: 'i',
34+
mode: InputOption::VALUE_REQUIRED,
35+
description: 'Read the JSON import payload from a file',
36+
)
37+
->addOption(
38+
name: 'dry-run',
39+
shortcut: null,
40+
mode: InputOption::VALUE_NONE,
41+
description: 'Validate the payload and report the import summary without persisting data',
42+
);
43+
}
44+
45+
#[\Override]
46+
protected function execute(InputInterface $input, OutputInterface $output): int {
47+
$sourcePath = $input->getOption('input');
48+
if (!is_string($sourcePath) || $sourcePath === '') {
49+
$output->writeln('<error>Please provide --input with a JSON file path.</error>');
50+
return self::FAILURE;
51+
}
52+
53+
$rawPayload = @file_get_contents($sourcePath);
54+
if ($rawPayload === false) {
55+
$output->writeln(sprintf('<error>Could not read import payload from %s.</error>', $sourcePath));
56+
return self::FAILURE;
57+
}
58+
59+
try {
60+
$decodedPayload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
61+
} catch (JsonException $exception) {
62+
$output->writeln('<error>Failed to decode import payload JSON.</error>');
63+
$output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));
64+
return self::FAILURE;
65+
}
66+
67+
if (!is_array($decodedPayload)) {
68+
$output->writeln('<error>Import payload must decode to a JSON object.</error>');
69+
return self::FAILURE;
70+
}
71+
72+
try {
73+
$summary = $this->dataImportService->import($decodedPayload, (bool)$input->getOption('dry-run'));
74+
} catch (\Throwable $throwable) {
75+
$output->writeln('<error>Import validation failed.</error>');
76+
$output->writeln(sprintf('<error>%s</error>', $throwable->getMessage()));
77+
return self::FAILURE;
78+
}
79+
80+
$output->writeln((bool)$input->getOption('dry-run')
81+
? '<info>Profile Fields data import dry-run completed.</info>'
82+
: '<info>Profile Fields data imported.</info>');
83+
$output->writeln(sprintf(
84+
'<info>Definitions: %d created, %d updated, %d skipped.</info>',
85+
$summary['created_definitions'],
86+
$summary['updated_definitions'],
87+
$summary['skipped_definitions'],
88+
));
89+
$output->writeln(sprintf(
90+
'<info>Values: %d created, %d updated, %d skipped.</info>',
91+
$summary['created_values'],
92+
$summary['updated_values'],
93+
$summary['skipped_values'],
94+
));
95+
96+
return self::SUCCESS;
97+
}
98+
}

lib/Listener/BeforeTemplateRenderedListener.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
/**
1717
* @template-implements IEventListener<Event>
1818
*/
19-
readonly class BeforeTemplateRenderedListener implements IEventListener {
19+
class BeforeTemplateRenderedListener implements IEventListener {
2020
#[\Override]
2121
public function handle(Event $event): void {
2222
if ($event::class !== '\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent') {

lib/ResponseDefinitions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
* user_uid: string,
4747
* value: ProfileFieldsValuePayload,
4848
* current_visibility: ProfileFieldsVisibility,
49+
* updated_by_uid: string,
50+
* updated_at: string,
4951
* }
5052
* @psalm-type ProfileFieldsEditableField = array{
5153
* definition: ProfileFieldsDefinition,

0 commit comments

Comments
 (0)