From cf2970a41aa51a548de5f8d5e3616887738cc6a6 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 8 Jun 2026 12:54:01 +0200 Subject: [PATCH] Add cross-client conflict notifications (step 3) Wird ein echter Feld-Konflikt aufgeloest, erfahren jetzt auch die ANDEREN Arbeitsplaetze davon - nicht nur der aufloesende Client. - ConflictNotice-Modell + ConflictNotifier: schreibt pro Konflikt eine Notiz in /conflicts/ (nach StarfaceId), liest beim Sync ungesehene Notizen zu eigenen Kontakten, zeigt sie als Tray-Hinweis und merkt sich gezeigte lokal (seen-conflicts.json). Veraltete Notizen (>7 Tage) werden aufgeraeumt. - MainForm: schreibt nach einem Sync mit Konflikten die Notizen und zeigt ausstehende Notizen anderer Clients (gefiltert auf eigene gemappte StarfaceIds). AcquireCrossClientLock nimmt jetzt das gemeinsame Verzeichnis als Parameter. - README/CHANGELOG aktualisiert. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + README.md | 9 + .../Models/ConflictNotice.cs | 25 +++ .../Services/ConflictNotifier.cs | 154 ++++++++++++++++++ src/StarfaceOutlookSync/UI/MainForm.cs | 50 +++++- 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 src/StarfaceOutlookSync/Models/ConflictNotice.cs create mode 100644 src/StarfaceOutlookSync/Services/ConflictNotifier.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3c2851..ff080c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). ### Hinzugefuegt +- **Clientuebergreifende Konflikt-Hinweise (Mehrplatz).** Wird ein echter + Feld-Konflikt aufgeloest, legt der Client eine Notiz im gemeinsamen Verzeichnis + (`conflicts/`) ab - verschluesselt nach Kontakt (StarfaceId). Jeder andere + Arbeitsplatz, der denselben Kontakt pflegt, bekommt die Meldung beim naechsten + Sync als Tray-Hinweis angezeigt (auch der, dessen Wert ueberschrieben wurde). + Bereits gezeigte Notizen merkt sich jeder Client lokal; alte Notizen werden + nach 7 Tagen aufgeraeumt. - **Clientuebergreifende Sync-Sperre (Mehrplatz).** In den Einstellungen laesst sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere diff --git a/README.md b/README.md index 3ac7d68a..cad88178 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,15 @@ umgekehrt), wird der zweite Lauf uebersprungen. Atomar per `Interlocked`. - Ist **kein** Verzeichnis konfiguriert oder es ist nicht erreichbar, laeuft der Sync ohne diese Sperre weiter (die lokale Sperre bleibt aktiv). +**Konflikt-Hinweise:** Wird im bidirektionalen Modus ein echter Feld-Konflikt +aufgeloest (dasselbe Feld auf beiden Seiten unterschiedlich geaendert, Outlook +gewinnt), legt der Client eine Notiz im Unterordner `conflicts/` des gemeinsamen +Verzeichnisses ab - zugeordnet ueber die StarfaceId des Kontakts. Andere +Arbeitsplaetze, die denselben Kontakt pflegen, zeigen die Meldung beim naechsten +Sync als Tray-Hinweis an (so erfaehrt auch der Arbeitsplatz, dessen Wert +ueberschrieben wurde, davon). Gezeigte Notizen merkt sich jeder Client lokal, +veraltete Notizen werden nach 7 Tagen entfernt. + > Hinweis: Im Mehrplatz-Betrieb sollte die **bidirektionale** Sync-Richtung > verwendet werden. Die Ein-Richtungs-Modi ("Ersetzen") wuerden die von anderen > Arbeitsplaetzen gepflegten Kontakte loeschen. diff --git a/src/StarfaceOutlookSync/Models/ConflictNotice.cs b/src/StarfaceOutlookSync/Models/ConflictNotice.cs new file mode 100644 index 00000000..712921ba --- /dev/null +++ b/src/StarfaceOutlookSync/Models/ConflictNotice.cs @@ -0,0 +1,25 @@ +namespace StarfaceOutlookSync.Models +{ + /// + /// Hinweis ueber einen aufgeloesten Feld-Konflikt, der im gemeinsamen + /// Verzeichnis abgelegt wird, damit auch andere Arbeitsplaetze (die denselben + /// Kontakt pflegen) erfahren, dass ein Wert ueberschrieben wurde. + /// + public class ConflictNotice + { + public string Id { get; set; } = ""; + public string ByHost { get; set; } = ""; + public string ByUser { get; set; } = ""; + public string TimestampUtc { get; set; } = ""; + 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; } = ""; + + public override string ToString() => + $"{ContactName}: Feld '{Field}' geaendert an Arbeitsplatz {ByHost} " + + $"(Outlook: '{OutlookValue}' / Starface: '{StarfaceValue}') -> {Winner} uebernommen"; + } +} diff --git a/src/StarfaceOutlookSync/Services/ConflictNotifier.cs b/src/StarfaceOutlookSync/Services/ConflictNotifier.cs new file mode 100644 index 00000000..de9b3d91 --- /dev/null +++ b/src/StarfaceOutlookSync/Services/ConflictNotifier.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using StarfaceOutlookSync.Models; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Verteilt Konflikt-Hinweise ueber das gemeinsame Verzeichnis an alle + /// Arbeitsplaetze. Wer einen echten Feld-Konflikt aufloest, legt eine Notiz + /// ab; andere Clients zeigen beim naechsten Sync die Notizen zu Kontakten, + /// die sie selbst gemappt haben (per StarfaceId). Bereits gezeigte Notizen + /// merkt sich jeder Client lokal, damit sie nicht doppelt erscheinen. + /// + public class ConflictNotifier + { + private static readonly TimeSpan Ttl = TimeSpan.FromDays(7); + + private readonly string _seenFile; + + public ConflictNotifier() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "StarfaceOutlookSync"); + try { Directory.CreateDirectory(dir); } catch { } + _seenFile = Path.Combine(dir, "seen-conflicts.json"); + } + + private static string ConflictsDir(string sharedDir) => Path.Combine(sharedDir, "conflicts"); + + /// Legt fuer jeden Konflikt eine Notiz im gemeinsamen Verzeichnis ab. + public void Write(string sharedDir, IEnumerable conflicts) + { + if (string.IsNullOrWhiteSpace(sharedDir)) return; + var list = conflicts?.ToList(); + if (list == null || list.Count == 0) return; + + try + { + var dir = ConflictsDir(sharedDir); + Directory.CreateDirectory(dir); + var host = Environment.MachineName; + var user = Environment.UserName; + var ts = DateTime.UtcNow.ToString("o"); + + foreach (var c in list) + { + var notice = new ConflictNotice + { + Id = Guid.NewGuid().ToString("N"), + ByHost = host, + ByUser = user, + TimestampUtc = ts, + StarfaceId = c.StarfaceId, + ContactName = c.ContactName, + Field = c.Field, + OutlookValue = c.OutlookValue, + StarfaceValue = c.StarfaceValue, + Winner = c.Winner + }; + try + { + File.WriteAllText(Path.Combine(dir, notice.Id + ".json"), + JsonConvert.SerializeObject(notice, Formatting.Indented)); + } + catch { } + } + } + catch { } + } + + /// + /// Liefert ungesehene Konflikt-Notizen, die Kontakte dieses Clients + /// betreffen (StarfaceId in myStarfaceIds) und nicht von ihm selbst + /// stammen. Markiert sie als gesehen und raeumt veraltete Notizen auf. + /// + public List GetPending(string sharedDir, ICollection myStarfaceIds) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(sharedDir)) return result; + + var dir = ConflictsDir(sharedDir); + string[] files; + try + { + if (!Directory.Exists(dir)) return result; + files = Directory.GetFiles(dir, "*.json"); + } + catch { return result; } + + var seen = LoadSeen(); + var host = Environment.MachineName; + var existingIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var f in files) + { + ConflictNotice n = null; + try { n = JsonConvert.DeserializeObject(File.ReadAllText(f)); } + catch { } + if (n == null || string.IsNullOrEmpty(n.Id)) continue; + + // Veraltete Notizen entfernen. + if (DateTime.TryParse(n.TimestampUtc, out var ts) + && DateTime.UtcNow - ts.ToUniversalTime() > Ttl) + { + try { File.Delete(f); } catch { } + continue; + } + existingIds.Add(n.Id); + + if (seen.Contains(n.Id)) continue; // schon gezeigt + if (string.Equals(n.ByHost, host, StringComparison.OrdinalIgnoreCase)) + { + seen.Add(n.Id); // selbst erzeugt -> nicht erneut anzeigen + continue; + } + if (myStarfaceIds != null && !string.IsNullOrEmpty(n.StarfaceId) + && !myStarfaceIds.Contains(n.StarfaceId)) + continue; // betrifft mich nicht + + result.Add(n); + seen.Add(n.Id); + } + + // Gesehen-Liste auf noch vorhandene Notizen eindampfen. + seen.IntersectWith(existingIds); + SaveSeen(seen); + + return result; + } + + private HashSet LoadSeen() + { + try + { + if (File.Exists(_seenFile)) + return new HashSet( + JsonConvert.DeserializeObject>(File.ReadAllText(_seenFile)) ?? new List(), + StringComparer.OrdinalIgnoreCase); + } + catch { } + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + private void SaveSeen(HashSet seen) + { + try { File.WriteAllText(_seenFile, JsonConvert.SerializeObject(seen.ToList())); } + catch { } + } + } +} diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs index 1e54156c..1a6ad107 100644 --- a/src/StarfaceOutlookSync/UI/MainForm.cs +++ b/src/StarfaceOutlookSync/UI/MainForm.cs @@ -16,6 +16,7 @@ namespace StarfaceOutlookSync.UI { private readonly ProfileManager _profileManager = new ProfileManager(); private readonly SyncEngine _syncEngine = new SyncEngine(); + private readonly ConflictNotifier _conflictNotifier = new ConflictNotifier(); private NotifyIcon _trayIcon; private ContextMenuStrip _trayMenu; private ListView _profileList; @@ -349,10 +350,11 @@ namespace StarfaceOutlookSync.UI } SyncLock crossLock = null; + var sharedDir = UserSettings.Load().SharedDirectory; try { // Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert). - crossLock = await AcquireCrossClientLock(); + crossLock = await AcquireCrossClientLock(sharedDir); if (crossLock == null) { SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen."); @@ -381,8 +383,14 @@ namespace StarfaceOutlookSync.UI detail += $"\n... und {result.Conflicts.Count - 5} weitere"; _trayIcon.ShowBalloonTip(10000, $"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning); + + // Andere Arbeitsplaetze ueber das gemeinsame Verzeichnis informieren. + _conflictNotifier.Write(sharedDir, result.Conflicts); } + // Konflikt-Hinweise von ANDEREN Arbeitsplaetzen (zu meinen Kontakten) anzeigen. + ShowRemoteConflictNotices(sharedDir); + SetStatus(msg); } catch (Exception ex) @@ -406,9 +414,8 @@ namespace StarfaceOutlookSync.UI /// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird /// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt). /// - private async Task AcquireCrossClientLock() + private async Task AcquireCrossClientLock(string dir) { - var dir = UserSettings.Load().SharedDirectory; if (string.IsNullOrWhiteSpace(dir)) return SyncLock.NoOp(); @@ -439,6 +446,43 @@ namespace StarfaceOutlookSync.UI } } + /// + /// Zeigt Konflikt-Hinweise an, die andere Arbeitsplaetze ueber das + /// gemeinsame Verzeichnis hinterlassen haben und Kontakte betreffen, die + /// auch dieser Client gemappt hat. + /// + private void ShowRemoteConflictNotices(string sharedDir) + { + if (string.IsNullOrWhiteSpace(sharedDir)) return; + try + { + var pending = _conflictNotifier.GetPending(sharedDir, AllMappedStarfaceIds()); + if (pending.Count == 0) return; + + var detail = string.Join("\n", pending.Take(5).Select(p => p.ToString())); + if (pending.Count > 5) + detail += $"\n... und {pending.Count - 5} weitere"; + _trayIcon.ShowBalloonTip(10000, + $"Konflikt an anderem Arbeitsplatz ({pending.Count})", detail, ToolTipIcon.Warning); + } + catch { } + } + + /// Alle von diesem Client gemappten StarfaceIds (ueber alle Profile). + private HashSet AllMappedStarfaceIds() + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var p in _profileManager.GetProfiles()) + foreach (var m in _profileManager.GetMappings(p.Id)) + if (!string.IsNullOrEmpty(m.StarfaceId)) + ids.Add(m.StarfaceId); + } + catch { } + return ids; + } + private void SetStatus(string text) { if (InvokeRequired)