feat: content-based matching for cross-instance migration (Closes #153) (#159)

This commit was merged in pull request #159.
This commit is contained in:
2026-04-09 20:43:58 +02:00
parent 454a7c23e1
commit bcb7bd7056
4 changed files with 188 additions and 9 deletions
+6 -2
View File
@@ -344,13 +344,15 @@ class ImportController extends ApiController {
$delimiter = $data['delimiter'] ?? ';'; $delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8'; $encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$result = $this->entityImportService->preview( $result = $this->entityImportService->preview(
$entityType, $entityType,
$decoded, $decoded,
$mapping, $mapping,
$delimiter, $delimiter,
$encoding $encoding,
(bool)$migrationMode
); );
return new JSONResponse($result); return new JSONResponse($result);
@@ -396,13 +398,15 @@ class ImportController extends ApiController {
$delimiter = $data['delimiter'] ?? ';'; $delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8'; $encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$result = $this->entityImportService->execute( $result = $this->entityImportService->execute(
$entityType, $entityType,
$decoded, $decoded,
$mapping, $mapping,
$delimiter, $delimiter,
$encoding $encoding,
(bool)$migrationMode
); );
return new JSONResponse($result); return new JSONResponse($result);
+108 -7
View File
@@ -206,14 +206,16 @@ class EntityImportService {
/** /**
* Run import in dry-run mode for any entity type. * Run import in dry-run mode for any entity type.
* *
* @return array{valid: array[], errors: array[], duplicates: array[], summary: array, fkWarnings: array[]} * @param bool $migrationMode When true, use content-based matching instead of ID-based
* @return array{valid: array[], errors: array[], duplicates: array[], summary: array, fkWarnings: array[], ambiguous: array[]}
*/ */
public function preview( public function preview(
string $type, string $type,
string $content, string $content,
array $mapping, array $mapping,
string $delimiter = ';', string $delimiter = ';',
string $encoding = 'UTF-8' string $encoding = 'UTF-8',
bool $migrationMode = false
): array { ): array {
$this->resetCaches(); $this->resetCaches();
$allRows = $this->parseAllRows($content, $delimiter, $encoding); $allRows = $this->parseAllRows($content, $delimiter, $encoding);
@@ -222,6 +224,7 @@ class EntityImportService {
$errors = []; $errors = [];
$duplicates = []; $duplicates = [];
$fkWarnings = []; $fkWarnings = [];
$ambiguous = [];
foreach ($allRows as $rowIndex => $row) { foreach ($allRows as $rowIndex => $row) {
$mapped = $this->applyMapping($row, $mapping); $mapped = $this->applyMapping($row, $mapping);
@@ -230,15 +233,24 @@ class EntityImportService {
// Validate // Validate
$rowErrors = $this->validateRow($type, $mapped, $rowNum); $rowErrors = $this->validateRow($type, $mapped, $rowNum);
// FK resolution warnings // FK resolution: use content-based in migration mode
$fkResult = $this->resolveFK($type, $mapped); if ($migrationMode) {
$fkResult = $this->resolveFKByContent($type, $mapped);
} else {
$fkResult = $this->resolveFK($type, $mapped);
}
if (!empty($fkResult['warnings'])) { if (!empty($fkResult['warnings'])) {
foreach ($fkResult['warnings'] as $w) { foreach ($fkResult['warnings'] as $w) {
$fkWarnings[] = ['row' => $rowNum, 'message' => $w]; $fkWarnings[] = ['row' => $rowNum, 'message' => $w];
} }
} }
if (!empty($fkResult['ambiguous'])) {
foreach ($fkResult['ambiguous'] as $a) {
$ambiguous[] = ['row' => $rowNum, 'message' => $a];
}
}
// Duplicate detection (returns existing record for conflict resolution) // In migration mode, always use content-based duplicate detection
$dupResult = $this->detectDuplicateWithExisting($type, $mapped); $dupResult = $this->detectDuplicateWithExisting($type, $mapped);
if ($dupResult !== false) { if ($dupResult !== false) {
@@ -262,6 +274,7 @@ class EntityImportService {
'errors' => $errors, 'errors' => $errors,
'duplicates' => $duplicates, 'duplicates' => $duplicates,
'fkWarnings' => $fkWarnings, 'fkWarnings' => $fkWarnings,
'ambiguous' => $ambiguous,
'summary' => [ 'summary' => [
'total' => count($allRows), 'total' => count($allRows),
'toCreate' => count($valid), 'toCreate' => count($valid),
@@ -276,6 +289,7 @@ class EntityImportService {
/** /**
* Execute import for any entity type. * Execute import for any entity type.
* *
* @param bool $migrationMode When true, use content-based FK resolution
* @return array{created: int, skipped: int, errors: int, details: array[]} * @return array{created: int, skipped: int, errors: int, details: array[]}
*/ */
public function execute( public function execute(
@@ -283,7 +297,8 @@ class EntityImportService {
string $content, string $content,
array $mapping, array $mapping,
string $delimiter = ';', string $delimiter = ';',
string $encoding = 'UTF-8' string $encoding = 'UTF-8',
bool $migrationMode = false
): array { ): array {
$this->resetCaches(); $this->resetCaches();
$allRows = $this->parseAllRows($content, $delimiter, $encoding); $allRows = $this->parseAllRows($content, $delimiter, $encoding);
@@ -313,7 +328,9 @@ class EntityImportService {
} }
try { try {
$fkResult = $this->resolveFK($type, $mapped); $fkResult = $migrationMode
? $this->resolveFKByContent($type, $mapped)
: $this->resolveFK($type, $mapped);
$resolved = $fkResult['resolved']; $resolved = $fkResult['resolved'];
$entity = $this->createEntity($type, $resolved, $now); $entity = $this->createEntity($type, $resolved, $now);
@@ -799,6 +816,90 @@ class EntityImportService {
return ['resolved' => $resolved, 'warnings' => $warnings]; return ['resolved' => $resolved, 'warnings' => $warnings];
} }
/**
* Resolve foreign keys by content/natural keys (migration mode).
*
* In migration mode, IDs from the source instance are meaningless.
* Instead, use the resolved-name columns to find matching records
* in the target database. Report ambiguous matches.
*
* Part of Issue #153.
*
* @return array{resolved: array, warnings: string[], ambiguous: string[]}
*/
private function resolveFKByContent(string $type, array $mapped): array {
$warnings = [];
$ambiguous = [];
$resolved = $mapped;
// In migration mode, always prefer name-based resolution over IDs
// (IDs from source instance are meaningless)
// Resolve Stufe by name
if (!empty($mapped['stufenname'])) {
$stufen = $this->getStufeByNameMap();
$key = strtolower(trim($mapped['stufenname']));
if (isset($stufen[$key])) {
$resolved['stufe_id'] = (string)$stufen[$key];
} else {
$warnings[] = "Stufe '{$mapped['stufenname']}' nicht in Zieldatenbank gefunden";
}
}
// Resolve Family by name
if (!empty($mapped['familienname'])) {
$families = $this->getFamilyByNameMap();
$key = strtolower(trim($mapped['familienname']));
if (isset($families[$key])) {
$resolved['family_id'] = (string)$families[$key];
} else {
$warnings[] = "Familie '{$mapped['familienname']}' nicht in Zieldatenbank gefunden";
}
}
// Resolve Member by name (check for ambiguous matches)
if (!empty($mapped['mitgliedername'])) {
$name = strtolower(trim($mapped['mitgliedername']));
$matches = [];
foreach ($this->memberMapper->findAll() as $m) {
$mName = strtolower($m->getVorname() . ' ' . $m->getNachname());
if ($mName === $name) {
$matches[] = $m;
}
}
if (count($matches) === 1) {
$resolved['member_id'] = (string)$matches[0]->getId();
} elseif (count($matches) > 1) {
// Ambiguous: multiple members with same name
$ambiguous[] = "Mitglied '{$mapped['mitgliedername']}' mehrdeutig ({$this->count($matches)} Treffer)";
$resolved['member_id'] = (string)$matches[0]->getId(); // Use first match as default
} else {
$warnings[] = "Mitglied '{$mapped['mitgliedername']}' nicht in Zieldatenbank gefunden";
}
}
// Resolve Lager by name
if (!empty($mapped['lagername'])) {
$lager = $this->getLagerByNameMap();
$key = strtolower(trim($mapped['lagername']));
if (isset($lager[$key])) {
$resolved['lager_id'] = (string)$lager[$key];
} else {
$warnings[] = "Lager '{$mapped['lagername']}' nicht in Zieldatenbank gefunden";
}
}
return ['resolved' => $resolved, 'warnings' => $warnings, 'ambiguous' => $ambiguous];
}
/**
* Count items in an array (helper to avoid name collision with PHP count).
*/
private function count(array $items): int {
return \count($items);
}
// ── Entity creation ──────────────────────────────────────────── // ── Entity creation ────────────────────────────────────────────
private function createEntity(string $type, array $data, string $now): \OCP\AppFramework\Db\Entity { private function createEntity(string $type, array $data, string $now): \OCP\AppFramework\Db\Entity {
+9
View File
@@ -53,6 +53,10 @@ export const useImportStore = defineStore('import', {
conflictResolutions: [], conflictResolutions: [],
/** @type {Object|null} Merge result after conflict resolution */ /** @type {Object|null} Merge result after conflict resolution */
mergeResult: null, mergeResult: null,
/** @type {boolean} Migration mode (content-based matching) */
migrationMode: false,
/** @type {Array} Ambiguous match warnings from migration mode */
ambiguousWarnings: [],
// ── Bundle import fields (Issue #151) ── // ── Bundle import fields (Issue #151) ──
/** @type {boolean} Whether importing a ZIP bundle */ /** @type {boolean} Whether importing a ZIP bundle */
@@ -182,10 +186,12 @@ export const useImportStore = defineStore('import', {
mapping: this.mapping, mapping: this.mapping,
delimiter: this.delimiter, delimiter: this.delimiter,
encoding: this.encoding, encoding: this.encoding,
migrationMode: this.migrationMode,
}) })
this.previewResult = response.data this.previewResult = response.data
this.fkWarnings = response.data.fkWarnings || [] this.fkWarnings = response.data.fkWarnings || []
this.ambiguousWarnings = response.data.ambiguous || []
this.step = 4 this.step = 4
} catch (err) { } catch (err) {
this.error = err.response?.data?.error || 'Fehler bei der Vorschau' this.error = err.response?.data?.error || 'Fehler bei der Vorschau'
@@ -210,6 +216,7 @@ export const useImportStore = defineStore('import', {
mapping: this.mapping, mapping: this.mapping,
delimiter: this.delimiter, delimiter: this.delimiter,
encoding: this.encoding, encoding: this.encoding,
migrationMode: this.migrationMode,
}) })
this.executeResult = response.data this.executeResult = response.data
@@ -348,6 +355,8 @@ export const useImportStore = defineStore('import', {
this.fkWarnings = [] this.fkWarnings = []
this.conflictResolutions = [] this.conflictResolutions = []
this.mergeResult = null this.mergeResult = null
this.migrationMode = false
this.ambiguousWarnings = []
this.isBundleImport = false this.isBundleImport = false
this.bundleAnalysis = null this.bundleAnalysis = null
this.bundlePreviewResult = null this.bundlePreviewResult = null
+65
View File
@@ -177,6 +177,18 @@
</select> </select>
</label> </label>
</div> </div>
<!-- Migration mode toggle (Issue #153) -->
<label class="import-wizard__checkbox import-wizard__migration-toggle">
<input v-model="importStore.migrationMode" type="checkbox">
<strong>Migrationsmodus</strong> -- Daten aus anderer Instanz importieren
(Zuordnung ueber Namen statt IDs)
</label>
<div v-if="importStore.migrationMode" class="import-wizard__migration-hint">
Im Migrationsmodus werden Datensaetze anhand ihres Inhalts (Name, Datum, etc.)
statt anhand der Datenbank-IDs zugeordnet. Verwende diesen Modus, wenn du Daten
aus einer anderen Nextcloud-Instanz importierst.
</div>
</div> </div>
<NcLoadingIcon v-if="importStore.loading" :size="32" /> <NcLoadingIcon v-if="importStore.loading" :size="32" />
@@ -392,6 +404,24 @@
</div> </div>
</div> </div>
<!-- Migration mode indicator -->
<div v-if="importStore.migrationMode" class="import-wizard__migration-active">
Migrationsmodus aktiv -- Zuordnung ueber Inhalte
</div>
<!-- Ambiguous matches (migration mode) -->
<div v-if="importStore.ambiguousWarnings.length > 0" class="import-wizard__section">
<h4>Mehrdeutige Zuordnungen</h4>
<p class="import-wizard__hint">
Die folgenden Datensaetze konnten nicht eindeutig zugeordnet werden.
Bitte pruefe die Zuordnung manuell.
</p>
<div v-for="(a, idx) in importStore.ambiguousWarnings" :key="'amb-' + idx"
class="import-wizard__ambiguous-row">
Zeile {{ a.row }}: {{ a.message }}
</div>
</div>
<!-- FK Warnings --> <!-- FK Warnings -->
<div v-if="importStore.fkWarnings.length > 0" class="import-wizard__section"> <div v-if="importStore.fkWarnings.length > 0" class="import-wizard__section">
<h4>Fremdschluessel-Warnungen</h4> <h4>Fremdschluessel-Warnungen</h4>
@@ -1021,4 +1051,39 @@ async function onExecuteWithMerge() {
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
} }
.import-wizard__migration-toggle {
margin-top: 16px;
padding: 10px 14px;
background: var(--color-background-dark);
border-radius: var(--border-radius);
}
.import-wizard__migration-hint {
padding: 8px 14px;
background: #eff6ff;
border-left: 3px solid var(--color-primary);
border-radius: var(--border-radius);
margin-top: 8px;
font-size: 0.85em;
color: var(--color-text-lighter);
}
.import-wizard__migration-active {
padding: 8px 14px;
background: var(--color-primary-element-light);
border-radius: var(--border-radius);
margin-bottom: 16px;
font-weight: 500;
font-size: 0.9em;
}
.import-wizard__ambiguous-row {
padding: 6px 12px;
background: #fef3c7;
border-left: 3px solid #f59e0b;
border-radius: var(--border-radius);
margin-bottom: 4px;
font-size: 0.9em;
}
</style> </style>