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
+12
View File
@@ -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))
+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();