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
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 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).
|
||||
/// </summary>
|
||||
private async Task<SyncLock> AcquireCrossClientLock()
|
||||
private async Task<SyncLock> AcquireCrossClientLock(string dir)
|
||||
{
|
||||
var dir = UserSettings.Load().SharedDirectory;
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
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)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
|
||||
Reference in New Issue
Block a user