Add "Dubletten zusammenfuehren" (merge duplicates) per profile

Neuer Button "Dubletten" im Hauptfenster fuehrt doppelte Kontakte fuer das
ausgewaehlte Profil zusammen - je Seite (Telefonanlage + Outlook) getrennt.

- Matching in ContactMatcher extrahiert (Sync UND Dedupe nutzen exakt dieselbe
  Erkennung).
- ContactMerger.FillEmptyInto / FilledFieldCount: leere Felder des Gewinners
  auffuellen (kein Datenverlust), Gewinner-Wahl nach Zuordnung/Vollstaendigkeit.
- DedupeService: Analyse (Vorschau) + Ausfuehrung (Merge, Loeschen der
  Dubletten, Bereinigen verwaister Zuordnungen).
- SyncCoordinator.RunExclusiveAsync: Dedupe laeuft unter demselben Guard +
  Lock-Datei wie ein Sync (nie gleichzeitig; kein Konflikt zwischen Clients).
- DedupeForm: Vorschau mit optionaler Detailliste, Bestaetigung, Live-Log;
  danach Hinweis, einmal zu synchronisieren.
- MainForm: Button "Dubletten" (Profil-Auswahl noetig), Fenster verbreitert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 13:11:16 +02:00
co-authored by Claude Opus 4.8
parent 9767299edd
commit ca05af75e3
8 changed files with 624 additions and 104 deletions
@@ -46,6 +46,34 @@ namespace StarfaceOutlookSync.Services
new FieldDef { Key = "Birthday", Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v },
};
/// <summary>
/// Fuellt LEERE Felder von target aus den sources auf (erste nicht-leere
/// Quelle gewinnt). Vorhandene Werte von target bleiben unveraendert -
/// kein Datenverlust. Gibt true zurueck, wenn sich etwas geaendert hat.
/// </summary>
public static bool FillEmptyInto(UnifiedContact target, IEnumerable<UnifiedContact> sources)
{
bool changed = false;
var list = sources?.ToList() ?? new List<UnifiedContact>();
foreach (var f in Fields)
{
if (!string.IsNullOrEmpty(f.Get(target))) continue;
foreach (var s in list)
{
var v = f.Get(s);
if (!string.IsNullOrEmpty(v)) { f.Set(target, v); changed = true; break; }
}
}
return changed;
}
/// <summary>Anzahl gefuellter Inhaltsfelder - fuer die Wahl des "Gewinners".</summary>
public static int FilledFieldCount(UnifiedContact c)
{
if (c == null) return 0;
return Fields.Count(f => !string.IsNullOrEmpty(f.Get(c)));
}
/// <summary>Liest den Wert eines Feldes per stabilem Schluessel.</summary>
public static string GetValue(UnifiedContact c, string key)
{