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)