From d3fa452504b092c6a003c0b3a6228501c0004836 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 8 Jun 2026 12:35:10 +0200 Subject: [PATCH] Add field-level 3-way merge for bidirectional conflicts Bisher wurde bei einem Konflikt (beide Seiten geaendert) der ganze Datensatz ueberschrieben - eine gleichzeitige Aenderung an einem anderen Feld ging verloren (z.B. A aendert Telefon in Outlook, B aendert Mail in Starface -> eine Aenderung weg). Jetzt: - Mapping speichert je Seite einen Snapshot des letzten Sync-Stands (LastOutlook/LastStarface), zusaetzlich zu den Hashes. - Bei beidseitiger Aenderung im Both-Modus wird feldweise gemergt (ContactMerger): unterschiedliche Felder bleiben beide erhalten, nur bei echtem Konflikt am selben Feld gewinnt Outlook. - Echte Feld-Konflikte landen in SyncResult.Conflicts und werden im MainForm per Tray-Meldung angezeigt. - Snapshots werden in allen Baseline-Punkten gesetzt (Phase 1-3) und fuer aeltere Mappings beim naechsten unveraenderten Sync nachgetragen. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++ src/StarfaceOutlookSync/Models/SyncProfile.cs | 25 ++++ .../Services/ContactMerger.cs | 129 ++++++++++++++++++ .../Services/SyncEngine.cs | 87 ++++++++++-- src/StarfaceOutlookSync/UI/MainForm.cs | 12 ++ 5 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 src/StarfaceOutlookSync/Services/ContactMerger.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c23d4b..e109be0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,17 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). wurde. Jede Seite hat jetzt eine eigene Baseline (`LastOutlookHash` / `LastStarfaceHash`); nur tatsaechlich geaenderte Kontakte werden geschrieben. +### Hinzugefuegt + +- **Feldweises 3-Wege-Merge bei Konflikten (bidirektional).** Wenn derselbe + Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt + Aenderungen an *unterschiedlichen* Feldern beide erhalten (z.B. einer aendert + die Telefonnummer in Outlook, ein anderer die E-Mail in Starface). Nur wenn + DASSELBE Feld auf beiden Seiten unterschiedlich geaendert wurde, greift die + Vorrang-Regel (Outlook gewinnt). Dafuer wird im Mapping zusaetzlich ein + Snapshot des letzten Sync-Stands je Seite gespeichert. Solche echten + Feld-Konflikte werden dem Benutzer per Tray-Meldung angezeigt. + ### Geaendert - **Doppelte Syncs verhindert (lokal).** Der Schutz gegen gleichzeitig laufende diff --git a/src/StarfaceOutlookSync/Models/SyncProfile.cs b/src/StarfaceOutlookSync/Models/SyncProfile.cs index ea196cd3..4d56b11c 100644 --- a/src/StarfaceOutlookSync/Models/SyncProfile.cs +++ b/src/StarfaceOutlookSync/Models/SyncProfile.cs @@ -53,6 +53,13 @@ namespace StarfaceOutlookSync.Models public string LastOutlookHash { get; set; } = ""; public string LastStarfaceHash { get; set; } = ""; + // Snapshot des zuletzt synchronisierten Stands je Seite. Ermoeglicht ein + // feldweises 3-Wege-Merge bei Konflikten (welche Seite hat welches Feld + // geaendert), statt den ganzen Datensatz zu ueberschreiben. Null bei + // Alt-Mappings -> dann Fallback auf ganz-ueberschreiben. + public UnifiedContact LastOutlook { get; set; } + public UnifiedContact LastStarface { get; set; } + // Alt-Feld (vor v0.0.0.24). Nur noch fuer Migration bestehender Mappings. public string LastSyncHash { get; set; } = ""; } @@ -65,5 +72,23 @@ namespace StarfaceOutlookSync.Models public int Updated { get; set; } public int Errors { get; set; } public System.Collections.Generic.List ErrorMessages { get; set; } = new System.Collections.Generic.List(); + + // Echte Feld-Konflikte (dasselbe Feld auf beiden Seiten geaendert), die + // ueber die Vorrang-Regel aufgeloest wurden. Fuer Benutzer-Hinweise. + public System.Collections.Generic.List Conflicts { get; set; } = new System.Collections.Generic.List(); + } + + public class FieldConflict + { + public string StarfaceId { get; set; } = ""; + public string ContactName { get; set; } = ""; + public string Field { get; set; } = ""; + public string OutlookValue { get; set; } = ""; + public string StarfaceValue { get; set; } = ""; + public string Winner { get; set; } = ""; // "Outlook" oder "Starface" + + public override string ToString() => + $"{ContactName}: Feld '{Field}' auf beiden Seiten geaendert " + + $"(Outlook: '{OutlookValue}' / Starface: '{StarfaceValue}') -> {Winner} uebernommen"; } } diff --git a/src/StarfaceOutlookSync/Services/ContactMerger.cs b/src/StarfaceOutlookSync/Services/ContactMerger.cs new file mode 100644 index 00000000..efd5703f --- /dev/null +++ b/src/StarfaceOutlookSync/Services/ContactMerger.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarfaceOutlookSync.Models; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Feldweises 3-Wege-Merge zweier Kontaktstaende gegen ihre Baseline. + /// Erlaubt es, dass zwei Stellen unterschiedliche Felder desselben Kontakts + /// aendern, ohne dass eine Aenderung verloren geht. Nur wenn DASSELBE Feld + /// auf beiden Seiten geaendert wurde, greift die Vorrang-Regel. + /// + public static class ContactMerger + { + private class FieldDef + { + public string Label; + public Func Get; + public Action Set; + public bool IsPhone; + } + + private static readonly FieldDef[] Fields = new[] + { + new FieldDef { Label = "Vorname", Get = c => c.FirstName, Set = (c, v) => c.FirstName = v }, + new FieldDef { Label = "Nachname", Get = c => c.LastName, Set = (c, v) => c.LastName = v }, + new FieldDef { Label = "Firma", Get = c => c.Company, Set = (c, v) => c.Company = v }, + new FieldDef { Label = "Position", Get = c => c.JobTitle, Set = (c, v) => c.JobTitle = v }, + new FieldDef { Label = "E-Mail", Get = c => c.Email, Set = (c, v) => c.Email = v }, + new FieldDef { Label = "E-Mail 2", Get = c => c.EmailSecondary, Set = (c, v) => c.EmailSecondary = v }, + new FieldDef { Label = "Telefon", Get = c => c.PhoneWork, Set = (c, v) => c.PhoneWork = v, IsPhone = true }, + new FieldDef { Label = "Mobil", Get = c => c.PhoneMobile, Set = (c, v) => c.PhoneMobile = v, IsPhone = true }, + new FieldDef { Label = "Telefon privat", Get = c => c.PhoneHome, Set = (c, v) => c.PhoneHome = v, IsPhone = true }, + new FieldDef { Label = "Fax", Get = c => c.Fax, Set = (c, v) => c.Fax = v, IsPhone = true }, + new FieldDef { Label = "Strasse", Get = c => c.Street, Set = (c, v) => c.Street = v }, + new FieldDef { Label = "Ort", Get = c => c.City, Set = (c, v) => c.City = v }, + new FieldDef { Label = "PLZ", Get = c => c.PostalCode, Set = (c, v) => c.PostalCode = v }, + new FieldDef { Label = "Bundesland", Get = c => c.State, Set = (c, v) => c.State = v }, + new FieldDef { Label = "Land", Get = c => c.Country, Set = (c, v) => c.Country = v }, + new FieldDef { Label = "Webseite", Get = c => c.Website, Set = (c, v) => c.Website = v }, + new FieldDef { Label = "Notizen", Get = c => c.Notes, Set = (c, v) => c.Notes = v }, + new FieldDef { Label = "Anrede", Get = c => c.Salutation, Set = (c, v) => c.Salutation = v }, + new FieldDef { Label = "Titel", Get = c => c.Title, Set = (c, v) => c.Title = v }, + new FieldDef { Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v }, + }; + + /// + /// Fuehrt den Outlook- und Starface-Stand gegen ihre jeweilige Baseline + /// zusammen. outlookWins entscheidet bei echten Feld-Konflikten. + /// + public static (UnifiedContact merged, List conflicts) Merge( + UnifiedContact baseOutlook, UnifiedContact baseStarface, + UnifiedContact outlook, UnifiedContact starface, + bool outlookWins) + { + var merged = new UnifiedContact + { + OutlookEntryId = outlook.OutlookEntryId, + StarfaceId = starface.StarfaceId + }; + var conflicts = new List(); + + foreach (var f in Fields) + { + string olv = f.Get(outlook) ?? ""; + string sfv = f.Get(starface) ?? ""; + string bol = f.Get(baseOutlook) ?? ""; + string bsf = f.Get(baseStarface) ?? ""; + + bool olChanged = !Equal(olv, bol, f.IsPhone); + bool sfChanged = !Equal(sfv, bsf, f.IsPhone); + + string chosen; + if (olChanged && !sfChanged) + { + chosen = olv; + } + else if (sfChanged && !olChanged) + { + chosen = sfv; + } + else if (olChanged && sfChanged) + { + if (Equal(olv, sfv, f.IsPhone)) + { + // Beide auf denselben Wert geaendert -> kein echter Konflikt. + chosen = olv; + } + else + { + chosen = outlookWins ? olv : sfv; + conflicts.Add(new FieldConflict + { + StarfaceId = starface.StarfaceId, + ContactName = outlook.DisplayName, + Field = f.Label, + OutlookValue = olv, + StarfaceValue = sfv, + Winner = outlookWins ? "Outlook" : "Starface" + }); + } + } + else + { + // Keine Seite hat dieses Feld geaendert -> Outlook-Wert behalten. + chosen = olv; + } + + f.Set(merged, chosen); + } + + return (merged, conflicts); + } + + private static bool Equal(string a, string b, bool isPhone) + { + if (isPhone) + return NormalizePhone(a) == NormalizePhone(b); + return string.Equals(a ?? "", b ?? "", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePhone(string phone) + { + if (string.IsNullOrEmpty(phone)) return ""; + return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray()); + } + } +} diff --git a/src/StarfaceOutlookSync/Services/SyncEngine.cs b/src/StarfaceOutlookSync/Services/SyncEngine.cs index 7f51b21b..932a5b9c 100644 --- a/src/StarfaceOutlookSync/Services/SyncEngine.cs +++ b/src/StarfaceOutlookSync/Services/SyncEngine.cs @@ -15,6 +15,20 @@ namespace StarfaceOutlookSync.Services private void Log(string message) => OnProgress?.Invoke(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 = ""; + } + /// /// Findet einen passenden Kontakt in der Kandidatenliste. /// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen @@ -282,9 +296,7 @@ namespace StarfaceOutlookSync.Services if (string.IsNullOrEmpty(mapping.LastOutlookHash) && string.IsNullOrEmpty(mapping.LastStarfaceHash)) { - mapping.LastOutlookHash = olHash; - mapping.LastStarfaceHash = sfHash; - mapping.LastSyncHash = ""; + SetBaseline(mapping, oc, sc); newMappings.Add(mapping); continue; } @@ -292,14 +304,23 @@ namespace StarfaceOutlookSync.Services 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.StarfaceAddressBook); if (updated != null) { - mapping.LastOutlookHash = olHash; - mapping.LastStarfaceHash = updated.GetHash(); + SetBaseline(mapping, oc, updated); result.Updated++; Log($" Aktualisiert (OL->SF): {oc.DisplayName}"); } @@ -310,22 +331,54 @@ namespace StarfaceOutlookSync.Services var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); if (updated != null) { - mapping.LastStarfaceHash = sfHash; - mapping.LastOutlookHash = updated.GetHash(); + SetBaseline(mapping, updated, sc); result.Updated++; Log($" Aktualisiert (SF->OL): {sc.DisplayName}"); } } else if (olChanged && sfChanged) { - // Beide geaendert -> Konflikt, Outlook bevorzugt - if (profile.SyncDirection != SyncDirection.StarfaceToOutlook) + // 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.StarfaceAddressBook); + 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); + Log(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.StarfaceAddressBook); if (updated != null) { - mapping.LastOutlookHash = olHash; - mapping.LastStarfaceHash = updated.GetHash(); + SetBaseline(mapping, oc, updated); result.Updated++; Log($" Konflikt (OL gewinnt): {oc.DisplayName}"); } @@ -335,14 +388,12 @@ namespace StarfaceOutlookSync.Services var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); if (updated != null) { - mapping.LastStarfaceHash = sfHash; - mapping.LastOutlookHash = updated.GetHash(); + SetBaseline(mapping, updated, sc); result.Updated++; Log($" Konflikt (SF gewinnt): {sc.DisplayName}"); } } } - // Beide unveraendert -> nichts tun } newMappings.Add(mapping); @@ -382,6 +433,8 @@ namespace StarfaceOutlookSync.Services ProfileId = profile.Id, OutlookEntryId = oc.OutlookEntryId, StarfaceId = match.StarfaceId, + LastOutlook = oc, + LastStarface = updated, LastOutlookHash = oc.GetHash(), LastStarfaceHash = updated.GetHash() }); @@ -403,6 +456,8 @@ namespace StarfaceOutlookSync.Services ProfileId = profile.Id, OutlookEntryId = oc.OutlookEntryId, StarfaceId = created.StarfaceId, + LastOutlook = oc, + LastStarface = created, LastOutlookHash = oc.GetHash(), LastStarfaceHash = created.GetHash() }); @@ -458,6 +513,8 @@ namespace StarfaceOutlookSync.Services ProfileId = profile.Id, OutlookEntryId = match.OutlookEntryId, StarfaceId = sc.StarfaceId, + LastStarface = sc, + LastOutlook = updated, LastStarfaceHash = sc.GetHash(), LastOutlookHash = updated.GetHash() }); @@ -478,6 +535,8 @@ namespace StarfaceOutlookSync.Services ProfileId = profile.Id, OutlookEntryId = created.OutlookEntryId, StarfaceId = sc.StarfaceId, + LastStarface = sc, + LastOutlook = created, LastStarfaceHash = sc.GetHash(), LastOutlookHash = created.GetHash() }); diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs index 4337e1da..b3cb5a64 100644 --- a/src/StarfaceOutlookSync/UI/MainForm.cs +++ b/src/StarfaceOutlookSync/UI/MainForm.cs @@ -358,10 +358,22 @@ namespace StarfaceOutlookSync.UI var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert"; if (result.Errors > 0) msg += $", {result.Errors} Fehler"; + if (result.Conflicts.Count > 0) msg += $", {result.Conflicts.Count} Konflikt(e)"; _trayIcon.ShowBalloonTip(3000, "Starface Sync", msg, result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info); + // Echte Feld-Konflikte gesondert melden, damit der Benutzer weiss, + // dass ein Wert ueberschrieben wurde. + if (result.Conflicts.Count > 0) + { + var detail = string.Join("\n", result.Conflicts.Take(5).Select(c => c.ToString())); + if (result.Conflicts.Count > 5) + detail += $"\n... und {result.Conflicts.Count - 5} weitere"; + _trayIcon.ShowBalloonTip(10000, + $"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning); + } + SetStatus(msg); } catch (Exception ex)