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 <shared>/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) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,13 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`).
|
|||||||
|
|
||||||
### Hinzugefuegt
|
### 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
|
- **Clientuebergreifende Sync-Sperre (Mehrplatz).** In den Einstellungen laesst
|
||||||
sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein
|
sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein
|
||||||
Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere
|
Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere
|
||||||
|
|||||||
@@ -298,6 +298,15 @@ umgekehrt), wird der zweite Lauf uebersprungen. Atomar per `Interlocked`.
|
|||||||
- Ist **kein** Verzeichnis konfiguriert oder es ist nicht erreichbar, laeuft der
|
- Ist **kein** Verzeichnis konfiguriert oder es ist nicht erreichbar, laeuft der
|
||||||
Sync ohne diese Sperre weiter (die lokale Sperre bleibt aktiv).
|
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
|
> Hinweis: Im Mehrplatz-Betrieb sollte die **bidirektionale** Sync-Richtung
|
||||||
> verwendet werden. Die Ein-Richtungs-Modi ("Ersetzen") wuerden die von anderen
|
> verwendet werden. Die Ein-Richtungs-Modi ("Ersetzen") wuerden die von anderen
|
||||||
> Arbeitsplaetzen gepflegten Kontakte loeschen.
|
> Arbeitsplaetzen gepflegten Kontakte loeschen.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace StarfaceOutlookSync.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>Legt fuer jeden Konflikt eine Notiz im gemeinsamen Verzeichnis ab.</summary>
|
||||||
|
public void Write(string sharedDir, IEnumerable<FieldConflict> 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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public List<ConflictNotice> GetPending(string sharedDir, ICollection<string> myStarfaceIds)
|
||||||
|
{
|
||||||
|
var result = new List<ConflictNotice>();
|
||||||
|
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<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
ConflictNotice n = null;
|
||||||
|
try { n = JsonConvert.DeserializeObject<ConflictNotice>(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<string> LoadSeen()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_seenFile))
|
||||||
|
return new HashSet<string>(
|
||||||
|
JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(_seenFile)) ?? new List<string>(),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSeen(HashSet<string> seen)
|
||||||
|
{
|
||||||
|
try { File.WriteAllText(_seenFile, JsonConvert.SerializeObject(seen.ToList())); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ namespace StarfaceOutlookSync.UI
|
|||||||
{
|
{
|
||||||
private readonly ProfileManager _profileManager = new ProfileManager();
|
private readonly ProfileManager _profileManager = new ProfileManager();
|
||||||
private readonly SyncEngine _syncEngine = new SyncEngine();
|
private readonly SyncEngine _syncEngine = new SyncEngine();
|
||||||
|
private readonly ConflictNotifier _conflictNotifier = new ConflictNotifier();
|
||||||
private NotifyIcon _trayIcon;
|
private NotifyIcon _trayIcon;
|
||||||
private ContextMenuStrip _trayMenu;
|
private ContextMenuStrip _trayMenu;
|
||||||
private ListView _profileList;
|
private ListView _profileList;
|
||||||
@@ -349,10 +350,11 @@ namespace StarfaceOutlookSync.UI
|
|||||||
}
|
}
|
||||||
|
|
||||||
SyncLock crossLock = null;
|
SyncLock crossLock = null;
|
||||||
|
var sharedDir = UserSettings.Load().SharedDirectory;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
|
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
|
||||||
crossLock = await AcquireCrossClientLock();
|
crossLock = await AcquireCrossClientLock(sharedDir);
|
||||||
if (crossLock == null)
|
if (crossLock == null)
|
||||||
{
|
{
|
||||||
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
|
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
|
||||||
@@ -381,8 +383,14 @@ namespace StarfaceOutlookSync.UI
|
|||||||
detail += $"\n... und {result.Conflicts.Count - 5} weitere";
|
detail += $"\n... und {result.Conflicts.Count - 5} weitere";
|
||||||
_trayIcon.ShowBalloonTip(10000,
|
_trayIcon.ShowBalloonTip(10000,
|
||||||
$"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning);
|
$"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);
|
SetStatus(msg);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -406,9 +414,8 @@ namespace StarfaceOutlookSync.UI
|
|||||||
/// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird
|
/// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird
|
||||||
/// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt).
|
/// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<SyncLock> AcquireCrossClientLock()
|
private async Task<SyncLock> AcquireCrossClientLock(string dir)
|
||||||
{
|
{
|
||||||
var dir = UserSettings.Load().SharedDirectory;
|
|
||||||
if (string.IsNullOrWhiteSpace(dir))
|
if (string.IsNullOrWhiteSpace(dir))
|
||||||
return SyncLock.NoOp();
|
return SyncLock.NoOp();
|
||||||
|
|
||||||
@@ -439,6 +446,43 @@ namespace StarfaceOutlookSync.UI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt Konflikt-Hinweise an, die andere Arbeitsplaetze ueber das
|
||||||
|
/// gemeinsame Verzeichnis hinterlassen haben und Kontakte betreffen, die
|
||||||
|
/// auch dieser Client gemappt hat.
|
||||||
|
/// </summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alle von diesem Client gemappten StarfaceIds (ueber alle Profile).</summary>
|
||||||
|
private HashSet<string> AllMappedStarfaceIds()
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>(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)
|
private void SetStatus(string text)
|
||||||
{
|
{
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
|
|||||||
Reference in New Issue
Block a user