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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarfaceOutlookSync.Models;
|
||||
|
||||
namespace StarfaceOutlookSync.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ContactMatcher
|
||||
{
|
||||
public 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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben.</summary>
|
||||
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<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StarfaceOutlookSync.Models;
|
||||
|
||||
namespace StarfaceOutlookSync.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class DedupeService
|
||||
{
|
||||
private readonly ProfileManager _profileManager = new ProfileManager();
|
||||
private readonly OutlookContactsService _outlookService = new OutlookContactsService();
|
||||
|
||||
public event Action<string> OnProgress;
|
||||
private void Log(string m) => OnProgress?.Invoke(m);
|
||||
|
||||
public class DupGroup
|
||||
{
|
||||
public UnifiedContact Survivor;
|
||||
public List<UnifiedContact> Duplicates = new List<UnifiedContact>();
|
||||
}
|
||||
|
||||
public class DedupePlan
|
||||
{
|
||||
public List<DupGroup> RemoteGroups = new List<DupGroup>(); // Telefonanlage
|
||||
public List<DupGroup> OutlookGroups = new List<DupGroup>();
|
||||
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<string> Messages = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>Nur analysieren (lesend), fuer die Vorschau.</summary>
|
||||
public async Task<DedupePlan> AnalyzeAsync(SyncProfile profile)
|
||||
{
|
||||
var plan = new DedupePlan();
|
||||
|
||||
var mappings = _profileManager.GetMappings(profile.Id);
|
||||
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
|
||||
var mappedOutlook = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fuehrt die Zusammenfuehrung wirklich aus. Laedt frisch (unter Sperre
|
||||
/// aufrufen!), gruppiert und wendet an. Gibt einen Bericht zurueck.
|
||||
/// </summary>
|
||||
public async Task<DedupeReport> ExecuteAsync(SyncProfile profile)
|
||||
{
|
||||
var report = new DedupeReport();
|
||||
|
||||
var mappings = _profileManager.GetMappings(profile.Id);
|
||||
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
|
||||
var mappedOutlook = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId));
|
||||
|
||||
var deletedStarfaceIds = new HashSet<string>();
|
||||
var deletedOutlookIds = new HashSet<string>();
|
||||
|
||||
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<DupGroup> GroupDuplicates(List<UnifiedContact> contacts, Func<UnifiedContact, bool> isMapped)
|
||||
{
|
||||
var groups = new List<DupGroup>();
|
||||
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<UnifiedContact> { 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<UnifiedContact> members, Func<UnifiedContact, bool> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,40 @@ namespace StarfaceOutlookSync.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<bool> RunExclusiveAsync(Func<Task> action, Action<string> 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<SyncLock> AcquireCrossClientLock(string dir, Action<string> status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Dialog zum Zusammenfuehren doppelter Kontakte fuer EIN Profil.
|
||||
/// Analysiert zuerst (Vorschau), fuehrt nach Bestaetigung unter derselben
|
||||
/// Sperre wie ein Sync zusammen.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user