From ca05af75e35489c4b49b4f01aaad59a2f8e02c96 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 17 Aug 2026 13:11:16 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 12 + .../Services/ContactMatcher.cs | 92 +++++++ .../Services/ContactMerger.cs | 28 +++ .../Services/DedupeService.cs | 238 ++++++++++++++++++ .../Services/SyncCoordinator.cs | 34 +++ .../Services/SyncEngine.cs | 103 +------- src/StarfaceOutlookSync/UI/DedupeForm.cs | 192 ++++++++++++++ src/StarfaceOutlookSync/UI/MainForm.cs | 29 ++- 8 files changed, 624 insertions(+), 104 deletions(-) create mode 100644 src/StarfaceOutlookSync/Services/ContactMatcher.cs create mode 100644 src/StarfaceOutlookSync/Services/DedupeService.cs create mode 100644 src/StarfaceOutlookSync/UI/DedupeForm.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d85a49f..36822e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). ## [Unreleased] +### Hinzugefuegt + +- **Dubletten zusammenfuehren (Button "Dubletten").** Fuehrt doppelte Kontakte + fuer das ausgewaehlte Profil zusammen - getrennt je Seite (Telefonanlage und + Outlook). Zuerst wird analysiert und eine Vorschau (optional mit Detailliste) + gezeigt; nach Bestaetigung bleibt je Gruppe ein "Gewinner", dessen leere + Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die uebrigen + werden geloescht. Verwaiste Zuordnungen werden bereinigt, sodass der naechste + bidirektionale Sync die Gewinner sauber 1:1 verbindet. Laeuft unter derselben + Sperre wie ein Sync (kein gleichzeitiger Lauf), nutzt dieselbe Kontakt- + Erkennung wie der Sync und wird vollstaendig protokolliert. + ### Behoben - **Dubletten bei Firmen-/Service-Eintraegen (nur Nummer + Firma).** Solche diff --git a/src/StarfaceOutlookSync/Services/ContactMatcher.cs b/src/StarfaceOutlookSync/Services/ContactMatcher.cs new file mode 100644 index 00000000..575ab220 --- /dev/null +++ b/src/StarfaceOutlookSync/Services/ContactMatcher.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarfaceOutlookSync.Models; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Erkennt, ob zwei Kontakte dieselbe Person/Firma sind. Wird sowohl vom + /// Sync (Wiederzuordnung, Duplikat-Vermeidung) als auch von der + /// Dubletten-Zusammenfuehrung genutzt - beide MUESSEN dieselbe Logik + /// verwenden, sonst laufen sie gegeneinander. + /// + public static class ContactMatcher + { + public static UnifiedContact FindMatch(UnifiedContact contact, List candidates) + { + if (candidates == null || candidates.Count == 0) return null; + foreach (var c in candidates) + if (IsMatch(contact, c)) + return c; + return null; + } + + public 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)); + + 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 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); + + bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch; + + if (emailMatch) return true; + 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: 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); + + /// True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben. + public 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 PhoneSet(UnifiedContact c) + { + var set = new HashSet(); + 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; + } + + public static string NormalizePhone(string phone) + { + if (string.IsNullOrEmpty(phone)) return ""; + var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + 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; + } + } +} diff --git a/src/StarfaceOutlookSync/Services/ContactMerger.cs b/src/StarfaceOutlookSync/Services/ContactMerger.cs index c55e8fe8..e9228c94 100644 --- a/src/StarfaceOutlookSync/Services/ContactMerger.cs +++ b/src/StarfaceOutlookSync/Services/ContactMerger.cs @@ -46,6 +46,34 @@ namespace StarfaceOutlookSync.Services new FieldDef { Key = "Birthday", Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v }, }; + /// + /// 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. + /// + public static bool FillEmptyInto(UnifiedContact target, IEnumerable sources) + { + bool changed = false; + var list = sources?.ToList() ?? new List(); + 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; + } + + /// Anzahl gefuellter Inhaltsfelder - fuer die Wahl des "Gewinners". + public static int FilledFieldCount(UnifiedContact c) + { + if (c == null) return 0; + return Fields.Count(f => !string.IsNullOrEmpty(f.Get(c))); + } + /// Liest den Wert eines Feldes per stabilem Schluessel. public static string GetValue(UnifiedContact c, string key) { diff --git a/src/StarfaceOutlookSync/Services/DedupeService.cs b/src/StarfaceOutlookSync/Services/DedupeService.cs new file mode 100644 index 00000000..64992f35 --- /dev/null +++ b/src/StarfaceOutlookSync/Services/DedupeService.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using StarfaceOutlookSync.Models; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Fuehrt Dubletten innerhalb EINES Profils zusammen - getrennt je Seite + /// (Telefonanlage und Outlook). Regel: ein "Gewinner" bleibt, dessen leere + /// Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die + /// uebrigen werden geloescht. Danach werden verwaiste Zuordnungen entfernt, + /// damit der naechste (bidirektionale) Sync die Gewinner sauber 1:1 verbindet. + /// Nutzt dieselbe Erkennung wie der Sync (ContactMatcher). + /// + public class DedupeService + { + private readonly ProfileManager _profileManager = new ProfileManager(); + private readonly OutlookContactsService _outlookService = new OutlookContactsService(); + + public event Action OnProgress; + private void Log(string m) => OnProgress?.Invoke(m); + + public class DupGroup + { + public UnifiedContact Survivor; + public List Duplicates = new List(); + } + + public class DedupePlan + { + public List RemoteGroups = new List(); // Telefonanlage + public List OutlookGroups = new List(); + public int RemoteDeletions => RemoteGroups.Sum(g => g.Duplicates.Count); + public int OutlookDeletions => OutlookGroups.Sum(g => g.Duplicates.Count); + public bool HasWork => RemoteGroups.Count > 0 || OutlookGroups.Count > 0; + } + + public class DedupeReport + { + public int RemoteMerged, RemoteDeleted, OutlookMerged, OutlookDeleted, Errors; + public List Messages = new List(); + } + + /// Nur analysieren (lesend), fuer die Vorschau. + public async Task AnalyzeAsync(SyncProfile profile) + { + var plan = new DedupePlan(); + + var mappings = _profileManager.GetMappings(profile.Id); + var mappedStarface = new HashSet(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId)); + var mappedOutlook = new HashSet(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId)); + + Log("Lade Outlook-Kontakte..."); + var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath); + + Log($"Verbinde mit {profile.Connection.System}..."); + using (var backend = ContactBackendFactory.Create(profile.Connection)) + { + backend.OnDebug += Log; + if (!await backend.LoginAsync()) + throw new Exception("Login an der Telefonanlage fehlgeschlagen."); + + Log("Lade Kontakte der Telefonanlage..."); + var remoteContacts = await backend.GetContactsAsync(profile.AddressBook); + await backend.LogoutAsync(); + + plan.RemoteGroups = GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId)); + plan.OutlookGroups = GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId)); + } + + Log($"Analyse fertig: Anlage {plan.RemoteGroups.Count} Gruppe(n)/{plan.RemoteDeletions} zu loeschen, " + + $"Outlook {plan.OutlookGroups.Count} Gruppe(n)/{plan.OutlookDeletions} zu loeschen."); + return plan; + } + + /// + /// Fuehrt die Zusammenfuehrung wirklich aus. Laedt frisch (unter Sperre + /// aufrufen!), gruppiert und wendet an. Gibt einen Bericht zurueck. + /// + public async Task ExecuteAsync(SyncProfile profile) + { + var report = new DedupeReport(); + + var mappings = _profileManager.GetMappings(profile.Id); + var mappedStarface = new HashSet(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId)); + var mappedOutlook = new HashSet(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId)); + + var deletedStarfaceIds = new HashSet(); + var deletedOutlookIds = new HashSet(); + + var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath); + + using (var backend = ContactBackendFactory.Create(profile.Connection)) + { + backend.OnDebug += Log; + if (!await backend.LoginAsync()) + throw new Exception("Login an der Telefonanlage fehlgeschlagen."); + + var remoteContacts = await backend.GetContactsAsync(profile.AddressBook); + + // --- Telefonanlage --- + foreach (var g in GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId))) + { + try + { + bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates); + bool survivorOk = true; + if (changed) + survivorOk = await backend.UpdateContactAsync(g.Survivor.StarfaceId, g.Survivor, profile.AddressBook) != null; + + if (!survivorOk) + { + // Gewinner konnte nicht ergaenzt werden -> Dubletten NICHT loeschen + // (sonst gingen die nur dort vorhandenen Felder verloren). + report.Errors++; + report.Messages.Add($"Anlage: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - Gruppe uebersprungen."); + continue; + } + + foreach (var dup in g.Duplicates) + { + if (await backend.DeleteContactAsync(dup.StarfaceId)) + { + deletedStarfaceIds.Add(dup.StarfaceId); + report.RemoteDeleted++; + Log($" Anlage: Dublette geloescht -> zusammengefuehrt in '{g.Survivor.DisplayName}'"); + } + } + report.RemoteMerged++; + } + catch (Exception ex) + { + report.Errors++; + report.Messages.Add($"Anlage '{g.Survivor.DisplayName}': {ex.Message}"); + } + } + + await backend.LogoutAsync(); + } + + // --- Outlook --- + foreach (var g in GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId))) + { + try + { + bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates); + bool survivorOk = true; + if (changed) + survivorOk = _outlookService.UpdateContact(g.Survivor.OutlookEntryId, g.Survivor) != null; + + if (!survivorOk) + { + report.Errors++; + report.Messages.Add($"Outlook: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - Gruppe uebersprungen."); + continue; + } + + foreach (var dup in g.Duplicates) + { + if (_outlookService.DeleteContact(dup.OutlookEntryId)) + { + deletedOutlookIds.Add(dup.OutlookEntryId); + report.OutlookDeleted++; + Log($" Outlook: Dublette geloescht -> zusammengefuehrt in '{g.Survivor.DisplayName}'"); + } + } + report.OutlookMerged++; + } + catch (Exception ex) + { + report.Errors++; + report.Messages.Add($"Outlook '{g.Survivor.DisplayName}': {ex.Message}"); + } + } + + // Verwaiste Zuordnungen (zeigen auf geloeschte Dubletten) entfernen. + // Der naechste Sync verbindet die Gewinner sauber neu. + if (deletedStarfaceIds.Count > 0 || deletedOutlookIds.Count > 0) + { + var kept = mappings + .Where(m => !deletedStarfaceIds.Contains(m.StarfaceId) && !deletedOutlookIds.Contains(m.OutlookEntryId)) + .ToList(); + _profileManager.SaveMappings(profile.Id, kept); + } + + return report; + } + + // ---- Gruppierung ---- + + private static List GroupDuplicates(List contacts, Func isMapped) + { + var groups = new List(); + if (contacts == null) return groups; + var used = new bool[contacts.Count]; + + for (int i = 0; i < contacts.Count; i++) + { + if (used[i]) continue; + var members = new List { contacts[i] }; + used[i] = true; + + for (int j = i + 1; j < contacts.Count; j++) + { + if (used[j]) continue; + if (members.Any(m => ContactMatcher.IsMatch(m, contacts[j]))) + { + members.Add(contacts[j]); + used[j] = true; + } + } + + if (members.Count > 1) + { + var survivor = ChooseSurvivor(members, isMapped); + groups.Add(new DupGroup + { + Survivor = survivor, + Duplicates = members.Where(m => !ReferenceEquals(m, survivor)).ToList() + }); + } + } + return groups; + } + + private static UnifiedContact ChooseSurvivor(List members, Func isMapped) + { + // 1) einen bereits zugeordneten bevorzugen (Kontinuitaet), + // 2) sonst den mit den meisten gefuellten Feldern. + return members + .OrderByDescending(m => isMapped(m) ? 1 : 0) + .ThenByDescending(m => ContactMerger.FilledFieldCount(m)) + .First(); + } + } +} diff --git a/src/StarfaceOutlookSync/Services/SyncCoordinator.cs b/src/StarfaceOutlookSync/Services/SyncCoordinator.cs index 98850f69..12273944 100644 --- a/src/StarfaceOutlookSync/Services/SyncCoordinator.cs +++ b/src/StarfaceOutlookSync/Services/SyncCoordinator.cs @@ -115,6 +115,40 @@ namespace StarfaceOutlookSync.Services } } + /// + /// Fuehrt eine beliebige exklusive Aktion (z.B. Dubletten zusammenfuehren) + /// unter demselben Schutz wie ein Sync aus: lokaler Guard + clientueber- + /// greifende Lock-Datei. Gibt false zurueck, wenn gerade ein Sync/eine + /// Aktion laeuft oder ein anderer Arbeitsplatz aktiv ist. + /// + public async Task RunExclusiveAsync(Func action, Action status) + { + if (Interlocked.CompareExchange(ref _running, 1, 0) != 0) + { + status?.Invoke("Es laeuft bereits ein Sync / eine Aktion - bitte warten."); + return false; + } + + SyncLock crossLock = null; + var sharedDir = UserSettings.Load().SharedDirectory; + try + { + crossLock = await AcquireCrossClientLock(sharedDir, status); + if (crossLock == null) + { + status?.Invoke("Anderer Arbeitsplatz ist gerade aktiv - abgebrochen."); + return false; + } + await action(); + return true; + } + finally + { + crossLock?.Dispose(); + Interlocked.Exchange(ref _running, 0); + } + } + private async Task AcquireCrossClientLock(string dir, Action status) { if (string.IsNullOrWhiteSpace(dir)) diff --git a/src/StarfaceOutlookSync/Services/SyncEngine.cs b/src/StarfaceOutlookSync/Services/SyncEngine.cs index 48b9297a..91ff8399 100644 --- a/src/StarfaceOutlookSync/Services/SyncEngine.cs +++ b/src/StarfaceOutlookSync/Services/SyncEngine.cs @@ -36,103 +36,6 @@ namespace StarfaceOutlookSync.Services m.LastSyncHash = ""; } - /// - /// 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. - /// - private static UnifiedContact FindMatch(UnifiedContact contact, List 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); - - /// True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben. - 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 PhoneSet(UnifiedContact c) - { - var set = new HashSet(); - 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 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 diff --git a/src/StarfaceOutlookSync/UI/DedupeForm.cs b/src/StarfaceOutlookSync/UI/DedupeForm.cs new file mode 100644 index 00000000..1363a84c --- /dev/null +++ b/src/StarfaceOutlookSync/UI/DedupeForm.cs @@ -0,0 +1,192 @@ +using System; +using System.Drawing; +using System.Threading.Tasks; +using System.Windows.Forms; +using StarfaceOutlookSync.Models; +using StarfaceOutlookSync.Services; + +namespace StarfaceOutlookSync.UI +{ + /// + /// Dialog zum Zusammenfuehren doppelter Kontakte fuer EIN Profil. + /// Analysiert zuerst (Vorschau), fuehrt nach Bestaetigung unter derselben + /// Sperre wie ein Sync zusammen. + /// + public class DedupeForm : Form + { + private readonly SyncProfile _profile; + private readonly DedupeService _dedupe = new DedupeService(); + private readonly SyncCoordinator _coordinator = new SyncCoordinator(); + + private Label _lblInfo, _lblSummary; + private CheckBox _chkDetail; + private TextBox _txtLog; + private Button _btnStart, _btnClose; + + private DedupeService.DedupePlan _plan; + private bool _busy; + + public DedupeForm(SyncProfile profile) + { + _profile = profile; + _dedupe.OnProgress += AppendLog; + InitializeComponent(); + Load += async (s, e) => await Analyze(); + } + + private void InitializeComponent() + { + Text = $"Dubletten zusammenfuehren - {_profile.Name}"; + Size = new Size(640, 500); + StartPosition = FormStartPosition.CenterParent; + Font = new Font("Segoe UI", 9); + MinimizeBox = false; + + _lblInfo = new Label + { + Text = $"Profil: {_profile.Name} ({_profile.Connection.System} <-> {_profile.OutlookFolderName})", + Left = 12, Top = 12, Width = 600, Height = 20 + }; + + _lblSummary = new Label + { + Text = "Analysiere...", + Left = 12, Top = 36, Width = 600, Height = 40, ForeColor = Color.DimGray + }; + + _chkDetail = new CheckBox + { + Text = "Detailvorschau anzeigen (welche Kontakte zusammengefuehrt werden)", + Left = 12, Top = 80, AutoSize = true, Enabled = false + }; + _chkDetail.CheckedChanged += (s, e) => { if (_chkDetail.Checked) RenderDetail(); }; + + _txtLog = new TextBox + { + Left = 12, Top = 108, Width = 604, Height = 300, + Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, + BackColor = Color.FromArgb(30, 30, 30), ForeColor = Color.FromArgb(212, 212, 212), + Font = new Font("Consolas", 9), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom + }; + + _btnStart = new Button + { + Text = "Zusammenfuehren starten", Left = 12, Top = 420, Width = 190, Height = 30, + Enabled = false, Anchor = AnchorStyles.Bottom | AnchorStyles.Left + }; + _btnStart.Click += async (s, e) => await Execute(); + + _btnClose = new Button + { + Text = "Schliessen", Left = 526, Top = 420, Width = 90, Height = 30, + DialogResult = DialogResult.Cancel, Anchor = AnchorStyles.Bottom | AnchorStyles.Right + }; + + Controls.AddRange(new Control[] { _lblInfo, _lblSummary, _chkDetail, _txtLog, _btnStart, _btnClose }); + CancelButton = _btnClose; + } + + private void AppendLog(string message) + { + if (InvokeRequired) { Invoke(new Action(() => AppendLog(message))); return; } + _txtLog.AppendText(message + "\r\n"); + } + + private async Task Analyze() + { + _busy = true; + _btnStart.Enabled = false; + try + { + _plan = await Task.Run(() => _dedupe.AnalyzeAsync(_profile)); + + var s = $"Telefonanlage: {_plan.RemoteGroups.Count} Gruppe(n) mit Dubletten, {_plan.RemoteDeletions} Kontakt(e) werden zusammengefuehrt.\r\n" + + $"Outlook: {_plan.OutlookGroups.Count} Gruppe(n) mit Dubletten, {_plan.OutlookDeletions} Kontakt(e) werden zusammengefuehrt."; + _lblSummary.Text = _plan.HasWork ? s : "Keine Dubletten gefunden. Alles sauber. ✓"; + _lblSummary.ForeColor = _plan.HasWork ? Color.OrangeRed : Color.Green; + + _chkDetail.Enabled = _plan.HasWork; + _btnStart.Enabled = _plan.HasWork; + if (_chkDetail.Checked) RenderDetail(); + } + catch (Exception ex) + { + _lblSummary.Text = "Analyse fehlgeschlagen: " + ex.Message; + _lblSummary.ForeColor = Color.Red; + AppendLog("FEHLER: " + ex.Message); + } + finally { _busy = false; } + } + + private void RenderDetail() + { + if (_plan == null) return; + AppendLog(""); + AppendLog("=== Vorschau: Telefonanlage ==="); + foreach (var g in _plan.RemoteGroups) + AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)"); + AppendLog("=== Vorschau: Outlook ==="); + foreach (var g in _plan.OutlookGroups) + AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)"); + AppendLog(""); + } + + private async Task Execute() + { + if (_busy || _plan == null || !_plan.HasWork) return; + + var confirm = MessageBox.Show(this, + $"Jetzt zusammenfuehren?\n\n" + + $"Telefonanlage: {_plan.RemoteDeletions} Dublette(n) werden geloescht.\n" + + $"Outlook: {_plan.OutlookDeletions} Dublette(n) werden geloescht.\n\n" + + $"Die Daten der Dubletten werden vorher in den behaltenen Kontakt uebernommen (leere Felder aufgefuellt).\n" + + $"Danach bitte einmal synchronisieren.", + "Dubletten zusammenfuehren", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (confirm != DialogResult.Yes) return; + + _busy = true; + _btnStart.Enabled = false; + _btnClose.Enabled = false; + AppendLog("Starte Zusammenfuehrung..."); + Logger.Log($"Dubletten-Zusammenfuehrung gestartet: Profil '{_profile.Name}'"); + + try + { + DedupeService.DedupeReport report = null; + bool ran = await _coordinator.RunExclusiveAsync( + async () => { report = await Task.Run(() => _dedupe.ExecuteAsync(_profile)); }, + status: AppendLog); + + if (!ran) + { + AppendLog("Abgebrochen (Sync/Aktion laeuft oder anderer Arbeitsplatz aktiv)."); + } + else if (report != null) + { + var summary = $"Fertig. Anlage: {report.RemoteMerged} Gruppe(n), {report.RemoteDeleted} geloescht. " + + $"Outlook: {report.OutlookMerged} Gruppe(n), {report.OutlookDeleted} geloescht. " + + $"Fehler: {report.Errors}."; + AppendLog(summary); + foreach (var m in report.Messages) AppendLog(" " + m); + AppendLog("Bitte jetzt einmal synchronisieren, damit die Zuordnungen sauber neu aufgebaut werden."); + Logger.Log("Dubletten-Zusammenfuehrung: " + summary); + foreach (var m in report.Messages) Logger.Log(" " + m); + + _lblSummary.Text = summary + " -> Jetzt synchronisieren."; + _lblSummary.ForeColor = report.Errors > 0 ? Color.OrangeRed : Color.Green; + } + } + catch (Exception ex) + { + AppendLog("FEHLER: " + ex.Message); + Logger.Log($"Dubletten-Zusammenfuehrung FEHLER '{_profile.Name}': {ex.Message}"); + } + finally + { + _busy = false; + _btnClose.Enabled = true; + } + } + } +} diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs index df34fd38..81cf540d 100644 --- a/src/StarfaceOutlookSync/UI/MainForm.cs +++ b/src/StarfaceOutlookSync/UI/MainForm.cs @@ -19,7 +19,7 @@ namespace StarfaceOutlookSync.UI private NotifyIcon _trayIcon; private ContextMenuStrip _trayMenu; private ListView _profileList; - private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnLog, _btnInfo; + private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo; private StatusStrip _statusBar; private ToolStripStatusLabel _statusLabel; private Timer _autoSyncTimer; @@ -83,8 +83,8 @@ namespace StarfaceOutlookSync.UI private void InitializeComponent() { Text = "Starface Kontakt-Sync"; - Size = new Size(830, 450); - MinimumSize = new Size(830, 350); + Size = new Size(930, 450); + MinimumSize = new Size(930, 350); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Segoe UI", 9); Icon = AppIcon.GetIcon(); @@ -126,6 +126,9 @@ namespace StarfaceOutlookSync.UI _btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 }; _btnSync.Click += async (s, e) => await SyncSelectedProfile(); + _btnDedupe = new Button { Text = "Dubletten", Width = 85, Height = 30 }; + _btnDedupe.Click += (s, e) => ShowDedupe(); + _btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 }; _btnReset.Click += (s, e) => ResetSync(); @@ -138,7 +141,7 @@ namespace StarfaceOutlookSync.UI _btnInfo = new Button { Text = "Info", Width = 50, Height = 30 }; _btnInfo.Click += (s, e) => ShowAbout(); - buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnLog, _btnInfo }); + buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo }); // Statusbar _statusBar = new StatusStrip(); @@ -458,6 +461,24 @@ namespace StarfaceOutlookSync.UI } } + private void ShowDedupe() + { + if (_profileList.SelectedItems.Count == 0) + { + MessageBox.Show("Bitte zuerst ein Profil auswaehlen.", "Dubletten", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var profile = _profileList.SelectedItems[0].Tag as SyncProfile; + if (profile == null) return; + + using (var form = new DedupeForm(profile)) + { + form.ShowDialog(this); + } + RefreshProfileList(); + } + private void ExitApplication() { _autoSyncTimer?.Stop();