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
+3 -100
View File
@@ -36,103 +36,6 @@ namespace StarfaceOutlookSync.Services
m.LastSyncHash = "";
}
/// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
/// auf der anderen auch gefuellt (und gleich) sein.
/// Ein leeres Feld auf einer Seite und ein gefuelltes auf der anderen
/// bedeutet: verschiedene Kontakte.
/// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
foreach (var c in candidates)
{
if (IsMatch(contact, c))
return c;
}
return null;
}
private static bool IsMatch(UnifiedContact a, UnifiedContact b)
{
bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
&& (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
// Starke Identifikatoren
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase)
&& (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase);
// Telefon feldUEBERGREIFEND vergleichen (die gleiche Nummer steht mal
// als geschaeftlich, mal als privat) und formatunabhaengig (+49 == 0).
bool phoneMatch = SharedPhone(a, b);
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Widerspruch: beide haben eine E-Mail, aber unterschiedlich -> verschiedene Personen.
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
// Gleiche E-Mail ist der staerkste Identifikator und reicht allein.
if (emailMatch) return true;
// Gleicher voller Name reicht, solange keine widerspruechliche E-Mail vorliegt.
// (Telefon-Umformatierung durch Starface darf einen Namens-Treffer NICHT verhindern.)
if (nameMatch && !emailContradiction) return true;
// Firma-/Service-Eintraege OHNE Personennamen: gleiche Firma + gemeinsame
// Nummer. Bewusst nur ohne Namen, damit nicht zwei Kollegen mit gleicher
// Zentrale-Nummer + Firma faelschlich verschmolzen werden.
bool aHasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName);
bool bHasName = !string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName);
if (phoneMatch && companyMatch && !aHasName && !bHasName && !emailContradiction) return true;
// Reine Nummern-Eintraege (kein Name, keine Firma, keine E-Mail auf beiden
// Seiten): eine gemeinsame Rufnummer identifiziert den Eintrag.
if (phoneMatch && IsBareNumberEntry(a) && IsBareNumberEntry(b)) return true;
return false;
}
private static bool IsBareNumberEntry(UnifiedContact c) =>
string.IsNullOrEmpty(c.FirstName) && string.IsNullOrEmpty(c.LastName)
&& string.IsNullOrEmpty(c.Company) && string.IsNullOrEmpty(c.Email);
/// <summary>True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben.</summary>
private static bool SharedPhone(UnifiedContact a, UnifiedContact b)
{
var pa = PhoneSet(a);
if (pa.Count == 0) return false;
var pb = PhoneSet(b);
return pb.Count > 0 && pa.Overlaps(pb);
}
private static HashSet<string> PhoneSet(UnifiedContact c)
{
var set = new HashSet<string>();
foreach (var p in new[] { c.PhoneWork, c.PhoneMobile, c.PhoneHome, c.Fax })
{
var n = NormalizePhone(p);
if (n.Length >= 5) set.Add(n); // zu kurze (z.B. reine Durchwahlen) ignorieren
}
return set;
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
// Landesvorwahl vereinheitlichen, damit +49/0049 und 0 gleich sind.
if (s.StartsWith("+49")) s = "0" + s.Substring(3);
else if (s.StartsWith("0049")) s = "0" + s.Substring(4);
else if (s.StartsWith("+")) s = s.Substring(1);
return s;
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
@@ -206,7 +109,7 @@ namespace StarfaceOutlookSync.Services
{
// Outlook-Kontakt nicht gefunden.
// Erst pruefen ob er vielleicht nur eine neue EntryID hat
var reMatch = FindMatch(sc, outlookContacts.Where(c =>
var reMatch = ContactMatcher.FindMatch(sc, outlookContacts.Where(c =>
!processedOutlookIds.Contains(c.OutlookEntryId)).ToList());
if (reMatch != null)
{
@@ -462,7 +365,7 @@ namespace StarfaceOutlookSync.Services
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
var match = ContactMatcher.FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
@@ -542,7 +445,7 @@ namespace StarfaceOutlookSync.Services
try
{
// Duplikat-Check: existiert der Kontakt schon in Outlook?
var match = FindMatch(sc, unmappedOutlook);
var match = ContactMatcher.FindMatch(sc, unmappedOutlook);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten