using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using StarfaceOutlookSync.Models; namespace StarfaceOutlookSync.Services { public class SyncEngine { private readonly ProfileManager _profileManager = new ProfileManager(); private readonly OutlookContactsService _outlookService = new OutlookContactsService(); public event Action OnProgress; private void Log(string message) => OnProgress?.Invoke(message); /// Protokolliert eine tatsaechliche Aenderung (fuer Live-Log UND SyncResult.Changes). private void Action(SyncResult result, string message) { Log(" " + message); result?.Changes.Add(message); } /// /// Setzt die Baseline eines Mappings auf den uebergebenen Stand beider /// Seiten (Snapshot + Hash). Der Snapshot wird fuer das Feld-Merge bei /// kuenftigen Konflikten gebraucht. /// private static void SetBaseline(SyncMapping m, UnifiedContact outlook, UnifiedContact starface) { m.LastOutlook = outlook; m.LastStarface = starface; m.LastOutlookHash = outlook?.GetHash() ?? ""; m.LastStarfaceHash = starface?.GetHash() ?? ""; m.LastSyncHash = ""; } public async Task SyncProfileAsync(SyncProfile profile) { var result = new SyncResult { ProfileName = profile.Name, Timestamp = DateTime.Now.ToString("o") }; try { Log("Verbinde mit Starface..."); using (var starface = ContactBackendFactory.Create(profile.Connection)) { starface.OnDebug += (msg) => Log(msg); var loginOk = await starface.LoginAsync(); if (!loginOk) { result.ErrorMessages.Add("Starface-Login fehlgeschlagen"); result.Errors++; return result; } // Kontakte laden Log("Lade Outlook-Kontakte..."); var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath); Log($"{outlookContacts.Count} Outlook-Kontakte geladen"); Log("Lade Starface-Kontakte..."); var starfaceContacts = await starface.GetContactsAsync(profile.AddressBook); Log($"{starfaceContacts.Count} Starface-Kontakte geladen"); // Bestehende Mappings laden var mappings = _profileManager.GetMappings(profile.Id); // Sets fuer schnellen Lookup var mappingByOutlook = new Dictionary(); var mappingByStarface = new Dictionary(); foreach (var m in mappings) { if (!string.IsNullOrEmpty(m.OutlookEntryId)) mappingByOutlook[m.OutlookEntryId] = m; if (!string.IsNullOrEmpty(m.StarfaceId)) mappingByStarface[m.StarfaceId] = m; } // Tracking: welche Kontakte wurden bereits verarbeitet var processedStarfaceIds = new HashSet(); var processedOutlookIds = new HashSet(); var newMappings = new List(); // ============================================ // Phase 1: Bestehende Mappings abgleichen // ============================================ Log("Gleiche bestehende Zuordnungen ab..."); foreach (var mapping in mappings.ToList()) { var oc = outlookContacts.FirstOrDefault(c => c.OutlookEntryId == mapping.OutlookEntryId); var sc = starfaceContacts.FirstOrDefault(c => c.StarfaceId == mapping.StarfaceId); if (oc != null) processedOutlookIds.Add(oc.OutlookEntryId); if (sc != null) processedStarfaceIds.Add(sc.StarfaceId); if (oc == null && sc == null) { // Beide Seiten geloescht -> Mapping entfernen Log($" Mapping verwaist (beide geloescht), entferne"); continue; } if (oc == null && sc != null) { // Outlook-Kontakt nicht gefunden. // Erst pruefen ob er vielleicht nur eine neue EntryID hat var reMatch = ContactMatcher.FindMatch(sc, outlookContacts.Where(c => !processedOutlookIds.Contains(c.OutlookEntryId)).ToList()); if (reMatch != null) { // Kontakt existiert noch in Outlook, nur EntryID geaendert Log($" EntryID geaendert, verknuepfe neu: {sc.DisplayName}"); mapping.OutlookEntryId = reMatch.OutlookEntryId; processedOutlookIds.Add(reMatch.OutlookEntryId); newMappings.Add(mapping); continue; } // Wirklich in Outlook geloescht. if (profile.SyncDirection == SyncDirection.OutlookToStarface) { // Outlook ist fuehrend -> Loeschung nach Starface spiegeln. if (await starface.DeleteContactAsync(mapping.StarfaceId)) { result.Updated++; Action(result, $"Geloescht (OL->SF): {sc.DisplayName}"); } continue; } if (profile.SyncDirection == SyncDirection.Both) { // Bidirektional: anhand der Baseline pruefen, ob die // Starface-Seite seit dem letzten Sync unveraendert ist. bool sfUnchanged = !string.IsNullOrEmpty(mapping.LastStarfaceHash) && sc.GetHash() == mapping.LastStarfaceHash; if (sfUnchanged) { // Unveraendert + in Outlook geloescht -> Loeschung gilt // -> auch aus Starface entfernen. if (await starface.DeleteContactAsync(mapping.StarfaceId)) { result.Updated++; Action(result, $"Geloescht (OL->SF): {sc.DisplayName}"); } continue; } // In Outlook geloescht, aber in Starface geaendert -> // Bearbeitung gewinnt, in Outlook neu anlegen (Phase 3). Log($" In Outlook geloescht, in Starface geaendert -> neu anlegen: {sc.DisplayName}"); processedStarfaceIds.Remove(sc.StarfaceId); continue; } // StarfaceToOutlook: Starface ist alleinige Quelle -> in Outlook // neu anlegen (Loeschung im Ziel zaehlt nicht). Log($" Outlook-Kontakt geloescht, wird neu angelegt: {sc.DisplayName}"); processedStarfaceIds.Remove(sc.StarfaceId); continue; } if (oc != null && sc == null) { // Starface-Kontakt nicht in der geladenen Liste. Zwei Faelle // unterscheiden, indem wir ihn per ID direkt abfragen: // (a) per ID noch vorhanden -> liegt in einem ANDEREN // Adressbuch -> Mapping behalten, NICHT neu anlegen // (sonst Dublette). // (b) per ID 404 -> in Starface WIRKLICH geloescht. bool stillExists = !string.IsNullOrEmpty(mapping.StarfaceId) && await starface.GetContactAsync(mapping.StarfaceId, profile.AddressBook) != null; if (stillExists) { Log($" Starface-Kontakt in anderem Adressbuch, behalte Mapping: {oc.DisplayName}"); newMappings.Add(mapping); continue; } // Wirklich in Starface geloescht. if (profile.SyncDirection == SyncDirection.StarfaceToOutlook) { // Starface ist fuehrend -> Loeschung nach Outlook spiegeln. if (_outlookService.DeleteContact(oc.OutlookEntryId)) { result.Updated++; Action(result, $"Geloescht (SF->OL): {oc.DisplayName}"); } continue; } if (profile.SyncDirection == SyncDirection.Both) { // Bidirektional: anhand der Baseline entscheiden, ob der // Outlook-Kontakt seit dem letzten Sync unveraendert ist. bool olUnchanged = !string.IsNullOrEmpty(mapping.LastOutlookHash) && oc.GetHash() == mapping.LastOutlookHash; if (olUnchanged) { // Unveraendert + in Starface geloescht -> Loeschung gilt // -> aus Outlook entfernen. if (_outlookService.DeleteContact(oc.OutlookEntryId)) { result.Updated++; Action(result, $"Geloescht (SF->OL): {oc.DisplayName}"); } continue; } // In Starface geloescht, aber in Outlook geaendert -> // Bearbeitung gewinnt, in Starface neu anlegen. Log($" In Starface geloescht, in Outlook geaendert -> neu anlegen: {oc.DisplayName}"); processedOutlookIds.Remove(oc.OutlookEntryId); continue; } // OutlookToStarface: Outlook ist alleinige Quelle -> Kontakt // in Starface neu anlegen (Loeschung im Ziel zaehlt nicht). Log($" Starface-Kontakt geloescht, wird neu angelegt: {oc.DisplayName}"); processedOutlookIds.Remove(oc.OutlookEntryId); continue; } if (oc != null && sc != null) { // Beide vorhanden -> auf Aenderungen pruefen. // WICHTIG: jede Seite gegen ihre EIGENE Baseline pruefen. // Outlook und Starface stellen denselben Kontakt // unterschiedlich dar, ein gemeinsamer Hash schlaegt nie an. var olHash = oc.GetHash(); var sfHash = sc.GetHash(); // Migration alter Mappings (nur LastSyncHash vorhanden): // aktuellen Stand als Baseline uebernehmen und als synchron // annehmen, damit kein Massen-Update ausgeloest wird. if (string.IsNullOrEmpty(mapping.LastOutlookHash) && string.IsNullOrEmpty(mapping.LastStarfaceHash)) { SetBaseline(mapping, oc, sc); newMappings.Add(mapping); continue; } bool olChanged = olHash != mapping.LastOutlookHash; bool sfChanged = sfHash != mapping.LastStarfaceHash; if (!olChanged && !sfChanged) { // Unveraendert. Snapshots aelterer Mappings (nur Hash) // nachtragen, damit kuenftige Konflikte gemergt werden koennen. if (mapping.LastOutlook == null || mapping.LastStarface == null) SetBaseline(mapping, oc, sc); newMappings.Add(mapping); continue; } if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)) { // Outlook hat sich geaendert -> Starface updaten var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.AddressBook); if (updated != null) { SetBaseline(mapping, oc, updated); result.Updated++; Action(result, $"Aktualisiert (OL->SF): {oc.DisplayName}"); } } else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)) { // Starface hat sich geaendert -> Outlook updaten var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); if (updated != null) { SetBaseline(mapping, updated, sc); result.Updated++; Action(result, $"Aktualisiert (SF->OL): {sc.DisplayName}"); } } else if (olChanged && sfChanged) { // Beide Seiten geaendert. if (profile.SyncDirection == SyncDirection.Both && mapping.LastOutlook != null && mapping.LastStarface != null) { // Feldweises 3-Wege-Merge: unterschiedliche Felder // bleiben beide erhalten; nur bei gleichem Feld auf // beiden Seiten gewinnt Outlook. var (merged, conflicts) = ContactMerger.Merge( mapping.LastOutlook, mapping.LastStarface, oc, sc, outlookWins: true); var updatedSf = await starface.UpdateContactAsync(mapping.StarfaceId, merged, profile.AddressBook); var updatedOl = _outlookService.UpdateContact(mapping.OutlookEntryId, merged); if (updatedSf != null || updatedOl != null) { if (updatedOl != null) { mapping.LastOutlook = updatedOl; mapping.LastOutlookHash = updatedOl.GetHash(); } if (updatedSf != null) { mapping.LastStarface = updatedSf; mapping.LastStarfaceHash = updatedSf.GetHash(); } mapping.LastSyncHash = ""; result.Updated++; foreach (var cf in conflicts) result.Conflicts.Add(cf); Action(result, conflicts.Count > 0 ? $"Beidseitig geaendert, zusammengefuehrt ({conflicts.Count} Feld-Konflikt(e), Outlook gewinnt): {oc.DisplayName}" : $"Beidseitig geaendert, zusammengefuehrt: {oc.DisplayName}"); } } else if (profile.SyncDirection != SyncDirection.StarfaceToOutlook) { // Fallback ohne Snapshot bzw. OutlookToStarface: // Outlook gewinnt komplett. var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.AddressBook); if (updated != null) { SetBaseline(mapping, oc, updated); result.Updated++; Action(result, $"Konflikt (OL gewinnt): {oc.DisplayName}"); } } else { var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); if (updated != null) { SetBaseline(mapping, updated, sc); result.Updated++; Action(result, $"Konflikt (SF gewinnt): {sc.DisplayName}"); } } } } newMappings.Add(mapping); } // ============================================ // Phase 2: Neue Outlook-Kontakte (ohne Mapping) // ============================================ if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface) { var unmappedOutlook = outlookContacts .Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId)) .ToList(); if (unmappedOutlook.Count > 0) Log($"Neue Outlook-Kontakte: {unmappedOutlook.Count}"); // Starface-Kontakte die noch kein Mapping haben (fuer Duplikat-Check) var unmappedStarface = starfaceContacts .Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId)) .ToList(); foreach (var oc in unmappedOutlook) { try { // Duplikat-Check: existiert der Kontakt schon in der Starface? var match = ContactMatcher.FindMatch(oc, unmappedStarface); if (match != null) { // Existiert schon -> verknuepfen und updaten var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.AddressBook); if (updated != null) { newMappings.Add(new SyncMapping { ProfileId = profile.Id, OutlookEntryId = oc.OutlookEntryId, StarfaceId = match.StarfaceId, LastOutlook = oc, LastStarface = updated, LastOutlookHash = oc.GetHash(), LastStarfaceHash = updated.GetHash() }); processedStarfaceIds.Add(match.StarfaceId); unmappedStarface.Remove(match); result.Updated++; Action(result, $"Verknuepft (OL->SF): {oc.DisplayName}"); } } else { // Neu -> in Starface erstellen Log($" Erstelle in Starface: {oc.DisplayName}"); var created = await starface.CreateContactAsync(oc, profile.AddressBook); if (created != null && !string.IsNullOrEmpty(created.StarfaceId)) { newMappings.Add(new SyncMapping { ProfileId = profile.Id, OutlookEntryId = oc.OutlookEntryId, StarfaceId = created.StarfaceId, LastOutlook = oc, LastStarface = created, LastOutlookHash = oc.GetHash(), LastStarfaceHash = created.GetHash() }); result.Created++; Action(result, $"Erstellt (OL->SF): {oc.DisplayName}"); } else { Log($" FEHLER: Kontakt konnte nicht erstellt werden: {oc.DisplayName}"); result.Errors++; } } } catch (Exception ex) { result.Errors++; result.ErrorMessages.Add($"OL->SF {oc.DisplayName}: {ex.Message}"); } } } // ============================================ // Phase 3: Neue Starface-Kontakte (ohne Mapping) // ============================================ if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook) { var unmappedStarface = starfaceContacts .Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId)) .ToList(); if (unmappedStarface.Count > 0) Log($"Neue Starface-Kontakte: {unmappedStarface.Count}"); // Outlook-Kontakte die noch kein Mapping haben (fuer Duplikat-Check) var unmappedOutlook = outlookContacts .Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId)) .ToList(); foreach (var sc in unmappedStarface) { try { // Duplikat-Check: existiert der Kontakt schon in Outlook? var match = ContactMatcher.FindMatch(sc, unmappedOutlook); if (match != null) { // Existiert schon -> verknuepfen und updaten var updated = _outlookService.UpdateContact(match.OutlookEntryId, sc); if (updated != null) { newMappings.Add(new SyncMapping { ProfileId = profile.Id, OutlookEntryId = match.OutlookEntryId, StarfaceId = sc.StarfaceId, LastStarface = sc, LastOutlook = updated, LastStarfaceHash = sc.GetHash(), LastOutlookHash = updated.GetHash() }); processedOutlookIds.Add(match.OutlookEntryId); unmappedOutlook.Remove(match); result.Updated++; Action(result, $"Verknuepft (SF->OL): {sc.DisplayName}"); } } else { // Neu -> in Outlook erstellen var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath); if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId)) { newMappings.Add(new SyncMapping { ProfileId = profile.Id, OutlookEntryId = created.OutlookEntryId, StarfaceId = sc.StarfaceId, LastStarface = sc, LastOutlook = created, LastStarfaceHash = sc.GetHash(), LastOutlookHash = created.GetHash() }); result.Created++; Action(result, $"Erstellt (SF->OL): {sc.DisplayName}"); } } } catch (Exception ex) { result.Errors++; result.ErrorMessages.Add($"SF->OL {sc.DisplayName}: {ex.Message}"); } } } // ============================================ // Phase 4: Ersetzen-Modus - Zielseite an Quelle angleichen // ============================================ // In den Ein-Richtungs-Modi soll die Zielseite eine exakte Kopie // der Quelle werden. Kontakte, die nur auf der Zielseite existieren // (kein Mapping, kein Treffer in der Quelle), werden geloescht. // Sicher, weil unvollstaendige Ladevorgaenge vorher abbrechen. if (profile.SyncDirection == SyncDirection.OutlookToStarface) { // Schutz gegen versehentliches Leerraeumen (z.B. falscher Ordner). if (outlookContacts.Count == 0) { Log("Ersetzen-Modus uebersprungen: Outlook-Ordner ist leer (Schutz vor versehentlichem Leeren)."); } else { var leftover = starfaceContacts .Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId)) .ToList(); if (leftover.Count > 0) Log($"Ersetzen-Modus: entferne {leftover.Count} Kontakt(e) aus Starface, die nicht in Outlook existieren"); foreach (var sc in leftover) { try { if (await starface.DeleteContactAsync(sc.StarfaceId)) { result.Updated++; Action(result, $"Geloescht (nur in Starface): {sc.DisplayName}"); } } catch (Exception ex) { result.Errors++; result.ErrorMessages.Add($"Loeschen SF {sc.DisplayName}: {ex.Message}"); } } } } else if (profile.SyncDirection == SyncDirection.StarfaceToOutlook) { if (starfaceContacts.Count == 0) { Log("Ersetzen-Modus uebersprungen: Starface-Adressbuch ist leer (Schutz vor versehentlichem Leeren)."); } else { var leftover = outlookContacts .Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId)) .ToList(); if (leftover.Count > 0) Log($"Ersetzen-Modus: entferne {leftover.Count} Kontakt(e) aus Outlook, die nicht in Starface existieren"); foreach (var oc in leftover) { try { if (_outlookService.DeleteContact(oc.OutlookEntryId)) { result.Updated++; Action(result, $"Geloescht (nur in Outlook): {oc.DisplayName}"); } } catch (Exception ex) { result.Errors++; result.ErrorMessages.Add($"Loeschen OL {oc.DisplayName}: {ex.Message}"); } } } } // Mappings speichern _profileManager.SaveMappings(profile.Id, newMappings); _profileManager.UpdateLastSync(profile.Id); await starface.LogoutAsync(); Log($"Fertig: {result.Created} erstellt, {result.Updated} aktualisiert, {result.Errors} Fehler"); } } catch (Exception ex) { result.Errors++; result.ErrorMessages.Add($"Allgemeiner Fehler: {ex.Message}"); } return result; } } }