Add "Dubletten zusammenfuehren" (merge duplicates) per profile

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

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

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