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'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$result = $this->entityImportService->preview(
$entityType,
$decoded,
$mapping,
$delimiter,
$encoding
$encoding,
(bool)$migrationMode
);
return new JSONResponse($result);
@@ -396,13 +398,15 @@ class ImportController extends ApiController {
$delimiter = $data['delimiter'] ?? ';';
$encoding = $data['encoding'] ?? 'UTF-8';
$migrationMode = $data['migrationMode'] ?? false;
$result = $this->entityImportService->execute(
$entityType,
$decoded,
$mapping,
$delimiter,
$encoding
$encoding,
(bool)$migrationMode
);
return new JSONResponse($result);
+108 -7
View File
@@ -206,14 +206,16 @@ class EntityImportService {
/**
* 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(
string $type,
string $content,
array $mapping,
string $delimiter = ';',
string $encoding = 'UTF-8'
string $encoding = 'UTF-8',
bool $migrationMode = false
): array {
$this->resetCaches();
$allRows = $this->parseAllRows($content, $delimiter, $encoding);
@@ -222,6 +224,7 @@ class EntityImportService {
$errors = [];
$duplicates = [];
$fkWarnings = [];
$ambiguous = [];
foreach ($allRows as $rowIndex => $row) {
$mapped = $this->applyMapping($row, $mapping);
@@ -230,15 +233,24 @@ class EntityImportService {
// Validate
$rowErrors = $this->validateRow($type, $mapped, $rowNum);
// FK resolution warnings
$fkResult = $this->resolveFK($type, $mapped);
// FK resolution: use content-based in migration mode
if ($migrationMode) {
$fkResult = $this->resolveFKByContent($type, $mapped);
} else {
$fkResult = $this->resolveFK($type, $mapped);
}
if (!empty($fkResult['warnings'])) {
foreach ($fkResult['warnings'] as $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);
if ($dupResult !== false) {
@@ -262,6 +274,7 @@ class EntityImportService {
'errors' => $errors,
'duplicates' => $duplicates,
'fkWarnings' => $fkWarnings,
'ambiguous' => $ambiguous,
'summary' => [
'total' => count($allRows),
'toCreate' => count($valid),
@@ -276,6 +289,7 @@ class EntityImportService {
/**
* 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[]}
*/
public function execute(
@@ -283,7 +297,8 @@ class EntityImportService {
string $content,
array $mapping,
string $delimiter = ';',
string $encoding = 'UTF-8'
string $encoding = 'UTF-8',
bool $migrationMode = false
): array {
$this->resetCaches();
$allRows = $this->parseAllRows($content, $delimiter, $encoding);
@@ -313,7 +328,9 @@ class EntityImportService {
}
try {
$fkResult = $this->resolveFK($type, $mapped);
$fkResult = $migrationMode
? $this->resolveFKByContent($type, $mapped)
: $this->resolveFK($type, $mapped);
$resolved = $fkResult['resolved'];
$entity = $this->createEntity($type, $resolved, $now);
@@ -799,6 +816,90 @@ class EntityImportService {
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 ────────────────────────────────────────────
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: [],
/** @type {Object|null} Merge result after conflict resolution */
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) ──
/** @type {boolean} Whether importing a ZIP bundle */
@@ -182,10 +186,12 @@ export const useImportStore = defineStore('import', {
mapping: this.mapping,
delimiter: this.delimiter,
encoding: this.encoding,
migrationMode: this.migrationMode,
})
this.previewResult = response.data
this.fkWarnings = response.data.fkWarnings || []
this.ambiguousWarnings = response.data.ambiguous || []
this.step = 4
} catch (err) {
this.error = err.response?.data?.error || 'Fehler bei der Vorschau'
@@ -210,6 +216,7 @@ export const useImportStore = defineStore('import', {
mapping: this.mapping,
delimiter: this.delimiter,
encoding: this.encoding,
migrationMode: this.migrationMode,
})
this.executeResult = response.data
@@ -348,6 +355,8 @@ export const useImportStore = defineStore('import', {
this.fkWarnings = []
this.conflictResolutions = []
this.mergeResult = null
this.migrationMode = false
this.ambiguousWarnings = []
this.isBundleImport = false
this.bundleAnalysis = null
this.bundlePreviewResult = null
+65
View File
@@ -177,6 +177,18 @@
</select>
</label>
</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>
<NcLoadingIcon v-if="importStore.loading" :size="32" />
@@ -392,6 +404,24 @@
</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 -->
<div v-if="importStore.fkWarnings.length > 0" class="import-wizard__section">
<h4>Fremdschluessel-Warnungen</h4>
@@ -1021,4 +1051,39 @@ async function onExecuteWithMerge() {
background: var(--color-primary);
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>