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();