Only notify clients actually affected by a conflict

Bisher bekam jeder Arbeitsplatz, der den Konflikt-Kontakt im Adressbuch hat,
den Hinweis - auch wenn er den Wert gar nicht selbst gepflegt hat.

Jetzt zeigt ein Client eine fremde Konflikt-Notiz nur, wenn er den Kontakt hat
UND sein eigener Feldwert vom uebernommenen (Gewinner-)Wert abweicht. Die
Pruefung laeuft VOR dem Sync (gegen den eigenen Mapping-Snapshot), bevor der
Sync den Stand auf den Gewinner-Wert angleicht.

- FieldConflict/ConflictNotice: stabiler FieldKey zusaetzlich zum Anzeige-Label.
- ContactMerger: GetValue/ValuesEqual per FieldKey (telefon-normalisiert).
- ConflictNotifier.GetPending: Filter "eigener Wert != Gewinner-Wert", bekommt
  StarfaceId -> eigener Kontaktstand.
- MainForm zeigt die Hinweise jetzt vor dem Sync und liefert den eigenen Stand
  aus den Mapping-Snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:01:02 +02:00
parent cf2970a41a
commit 257b6fb33d
7 changed files with 98 additions and 47 deletions
+6 -5
View File
@@ -40,11 +40,12 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`).
- **Clientuebergreifende Konflikt-Hinweise (Mehrplatz).** Wird ein echter - **Clientuebergreifende Konflikt-Hinweise (Mehrplatz).** Wird ein echter
Feld-Konflikt aufgeloest, legt der Client eine Notiz im gemeinsamen Verzeichnis Feld-Konflikt aufgeloest, legt der Client eine Notiz im gemeinsamen Verzeichnis
(`conflicts/`) ab - verschluesselt nach Kontakt (StarfaceId). Jeder andere (`conflicts/`) ab - zugeordnet nach Kontakt (StarfaceId). Ein anderer
Arbeitsplatz, der denselben Kontakt pflegt, bekommt die Meldung beim naechsten Arbeitsplatz zeigt die Meldung beim naechsten Sync nur an, wenn er den Kontakt
Sync als Tray-Hinweis angezeigt (auch der, dessen Wert ueberschrieben wurde). selbst hat UND sein eigener Feldwert vom uebernommenen Wert abweicht - so wird
Bereits gezeigte Notizen merkt sich jeder Client lokal; alte Notizen werden der ueberschriebene Arbeitsplatz gewarnt, waehrend bereits aktuelle Clients
nach 7 Tagen aufgeraeumt. nicht benachrichtigt werden. 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
+6 -5
View File
@@ -301,11 +301,12 @@ umgekehrt), wird der zweite Lauf uebersprungen. Atomar per `Interlocked`.
**Konflikt-Hinweise:** Wird im bidirektionalen Modus ein echter Feld-Konflikt **Konflikt-Hinweise:** Wird im bidirektionalen Modus ein echter Feld-Konflikt
aufgeloest (dasselbe Feld auf beiden Seiten unterschiedlich geaendert, Outlook aufgeloest (dasselbe Feld auf beiden Seiten unterschiedlich geaendert, Outlook
gewinnt), legt der Client eine Notiz im Unterordner `conflicts/` des gemeinsamen gewinnt), legt der Client eine Notiz im Unterordner `conflicts/` des gemeinsamen
Verzeichnisses ab - zugeordnet ueber die StarfaceId des Kontakts. Andere Verzeichnisses ab - zugeordnet ueber die StarfaceId des Kontakts. Beim naechsten
Arbeitsplaetze, die denselben Kontakt pflegen, zeigen die Meldung beim naechsten Sync zeigt ein anderer Arbeitsplatz die Meldung als Tray-Hinweis - aber **nur,
Sync als Tray-Hinweis an (so erfaehrt auch der Arbeitsplatz, dessen Wert wenn er den Kontakt selbst hat UND sein eigener Feldwert vom uebernommenen Wert
ueberschrieben wurde, davon). Gezeigte Notizen merkt sich jeder Client lokal, abweicht** (so erfaehrt der ueberschriebene Arbeitsplatz davon, waehrend Clients,
veraltete Notizen werden nach 7 Tagen entfernt. die den Wert ohnehin schon haben, nicht benachrichtigt werden). 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
@@ -14,6 +14,7 @@ namespace StarfaceOutlookSync.Models
public string StarfaceId { get; set; } = ""; public string StarfaceId { get; set; } = "";
public string ContactName { get; set; } = ""; public string ContactName { get; set; } = "";
public string Field { get; set; } = ""; public string Field { get; set; } = "";
public string FieldKey { get; set; } = "";
public string OutlookValue { get; set; } = ""; public string OutlookValue { get; set; } = "";
public string StarfaceValue { get; set; } = ""; public string StarfaceValue { get; set; } = "";
public string Winner { get; set; } = ""; public string Winner { get; set; } = "";
@@ -82,7 +82,8 @@ namespace StarfaceOutlookSync.Models
{ {
public string StarfaceId { get; set; } = ""; public string StarfaceId { get; set; } = "";
public string ContactName { get; set; } = ""; public string ContactName { get; set; } = "";
public string Field { get; set; } = ""; public string Field { get; set; } = ""; // Anzeige-Label, z.B. "E-Mail"
public string FieldKey { get; set; } = ""; // stabiler Schluessel, z.B. "Email"
public string OutlookValue { get; set; } = ""; public string OutlookValue { get; set; } = "";
public string StarfaceValue { get; set; } = ""; public string StarfaceValue { get; set; } = "";
public string Winner { get; set; } = ""; // "Outlook" oder "Starface" public string Winner { get; set; } = ""; // "Outlook" oder "Starface"
@@ -57,6 +57,7 @@ namespace StarfaceOutlookSync.Services
StarfaceId = c.StarfaceId, StarfaceId = c.StarfaceId,
ContactName = c.ContactName, ContactName = c.ContactName,
Field = c.Field, Field = c.Field,
FieldKey = c.FieldKey,
OutlookValue = c.OutlookValue, OutlookValue = c.OutlookValue,
StarfaceValue = c.StarfaceValue, StarfaceValue = c.StarfaceValue,
Winner = c.Winner Winner = c.Winner
@@ -74,10 +75,13 @@ namespace StarfaceOutlookSync.Services
/// <summary> /// <summary>
/// Liefert ungesehene Konflikt-Notizen, die Kontakte dieses Clients /// Liefert ungesehene Konflikt-Notizen, die Kontakte dieses Clients
/// betreffen (StarfaceId in myStarfaceIds) und nicht von ihm selbst /// betreffen und nicht von ihm selbst stammen. Es werden nur die Notizen
/// stammen. Markiert sie als gesehen und raeumt veraltete Notizen auf. /// zurueckgegeben, bei denen der EIGENE aktuelle Feldwert vom uebernommenen
/// (Gewinner-)Wert abweicht - wer den Wert ohnehin schon hat, wird nicht
/// benachrichtigt. myContacts bildet StarfaceId -> eigener Kontaktstand ab.
/// Markiert verarbeitete Notizen als gesehen und raeumt veraltete auf.
/// </summary> /// </summary>
public List<ConflictNotice> GetPending(string sharedDir, ICollection<string> myStarfaceIds) public List<ConflictNotice> GetPending(string sharedDir, IDictionary<string, UnifiedContact> myContacts)
{ {
var result = new List<ConflictNotice>(); var result = new List<ConflictNotice>();
if (string.IsNullOrWhiteSpace(sharedDir)) return result; if (string.IsNullOrWhiteSpace(sharedDir)) return result;
@@ -117,9 +121,29 @@ namespace StarfaceOutlookSync.Services
seen.Add(n.Id); // selbst erzeugt -> nicht erneut anzeigen seen.Add(n.Id); // selbst erzeugt -> nicht erneut anzeigen
continue; continue;
} }
if (myStarfaceIds != null && !string.IsNullOrEmpty(n.StarfaceId)
&& !myStarfaceIds.Contains(n.StarfaceId)) // Habe ich diesen Kontakt ueberhaupt?
continue; // betrifft mich nicht UnifiedContact mine = null;
bool haveContact = !string.IsNullOrEmpty(n.StarfaceId)
&& myContacts != null
&& myContacts.TryGetValue(n.StarfaceId, out mine);
if (!haveContact)
continue; // betrifft mich nicht (kein seen -> evtl. spaeter relevant)
// Bin ich wirklich betroffen? Nur wenn mein aktueller Feldwert vom
// uebernommenen Wert abweicht. Wer den Gewinner-Wert schon hat, wird
// nicht benachrichtigt.
if (mine != null && !string.IsNullOrEmpty(n.FieldKey))
{
var winnerValue = string.Equals(n.Winner, "Outlook", StringComparison.OrdinalIgnoreCase)
? n.OutlookValue : n.StarfaceValue;
var myValue = ContactMerger.GetValue(mine, n.FieldKey);
if (ContactMerger.ValuesEqual(n.FieldKey, myValue, winnerValue))
{
seen.Add(n.Id); // nicht betroffen -> als erledigt merken
continue;
}
}
result.Add(n); result.Add(n);
seen.Add(n.Id); seen.Add(n.Id);
@@ -15,6 +15,7 @@ namespace StarfaceOutlookSync.Services
{ {
private class FieldDef private class FieldDef
{ {
public string Key;
public string Label; public string Label;
public Func<UnifiedContact, string> Get; public Func<UnifiedContact, string> Get;
public Action<UnifiedContact, string> Set; public Action<UnifiedContact, string> Set;
@@ -23,28 +24,43 @@ namespace StarfaceOutlookSync.Services
private static readonly FieldDef[] Fields = new[] private static readonly FieldDef[] Fields = new[]
{ {
new FieldDef { Label = "Vorname", Get = c => c.FirstName, Set = (c, v) => c.FirstName = v }, new FieldDef { Key = "FirstName", 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 { Key = "LastName", 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 { Key = "Company", 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 { Key = "JobTitle", 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 { Key = "Email", 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 { Key = "EmailSecondary", 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 { Key = "PhoneWork", 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 { Key = "PhoneMobile", 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 { Key = "PhoneHome", 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 { Key = "Fax", 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 { Key = "Street", 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 { Key = "City", 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 { Key = "PostalCode", 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 { Key = "State", 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 { Key = "Country", 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 { Key = "Website", 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 { Key = "Notes", 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 { Key = "Salutation", 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 { Key = "Title", 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 }, new FieldDef { Key = "Birthday", Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v },
}; };
/// <summary>Liest den Wert eines Feldes per stabilem Schluessel.</summary>
public static string GetValue(UnifiedContact c, string key)
{
if (c == null || string.IsNullOrEmpty(key)) return "";
var f = Fields.FirstOrDefault(x => x.Key == key);
return f == null ? "" : (f.Get(c) ?? "");
}
/// <summary>Vergleicht zwei Feldwerte (telefon-normalisiert je nach Feld).</summary>
public static bool ValuesEqual(string key, string a, string b)
{
var f = Fields.FirstOrDefault(x => x.Key == key);
return Equal(a ?? "", b ?? "", f?.IsPhone ?? false);
}
/// <summary> /// <summary>
/// Fuehrt den Outlook- und Starface-Stand gegen ihre jeweilige Baseline /// Fuehrt den Outlook- und Starface-Stand gegen ihre jeweilige Baseline
/// zusammen. outlookWins entscheidet bei echten Feld-Konflikten. /// zusammen. outlookWins entscheidet bei echten Feld-Konflikten.
@@ -95,6 +111,7 @@ namespace StarfaceOutlookSync.Services
StarfaceId = starface.StarfaceId, StarfaceId = starface.StarfaceId,
ContactName = outlook.DisplayName, ContactName = outlook.DisplayName,
Field = f.Label, Field = f.Label,
FieldKey = f.Key,
OutlookValue = olv, OutlookValue = olv,
StarfaceValue = sfv, StarfaceValue = sfv,
Winner = outlookWins ? "Outlook" : "Starface" Winner = outlookWins ? "Outlook" : "Starface"
+16 -10
View File
@@ -361,6 +361,11 @@ namespace StarfaceOutlookSync.UI
return; return;
} }
// Konflikt-Hinweise von ANDEREN Arbeitsplaetzen anzeigen, BEVOR der
// Sync den eigenen Stand auf den Gewinner-Wert aktualisiert (sonst
// koennte man nicht mehr erkennen, ob man betroffen war).
ShowRemoteConflictNotices(sharedDir);
SetStatus($"Synchronisiere '{profile.Name}'..."); SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync", _trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info); $"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
@@ -388,9 +393,6 @@ namespace StarfaceOutlookSync.UI
_conflictNotifier.Write(sharedDir, result.Conflicts); _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)
@@ -456,7 +458,7 @@ namespace StarfaceOutlookSync.UI
if (string.IsNullOrWhiteSpace(sharedDir)) return; if (string.IsNullOrWhiteSpace(sharedDir)) return;
try try
{ {
var pending = _conflictNotifier.GetPending(sharedDir, AllMappedStarfaceIds()); var pending = _conflictNotifier.GetPending(sharedDir, MyContactsByStarfaceId());
if (pending.Count == 0) return; if (pending.Count == 0) return;
var detail = string.Join("\n", pending.Take(5).Select(p => p.ToString())); var detail = string.Join("\n", pending.Take(5).Select(p => p.ToString()));
@@ -468,19 +470,23 @@ namespace StarfaceOutlookSync.UI
catch { } catch { }
} }
/// <summary>Alle von diesem Client gemappten StarfaceIds (ueber alle Profile).</summary> /// <summary>
private HashSet<string> AllMappedStarfaceIds() /// Eigener Kontaktstand je StarfaceId (ueber alle Profile), aus den
/// Mapping-Snapshots. Dient dazu, bei Konflikt-Notizen zu pruefen, ob der
/// eigene Feldwert ueberhaupt vom uebernommenen Wert abweicht.
/// </summary>
private Dictionary<string, UnifiedContact> MyContactsByStarfaceId()
{ {
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var map = new Dictionary<string, UnifiedContact>(StringComparer.OrdinalIgnoreCase);
try try
{ {
foreach (var p in _profileManager.GetProfiles()) foreach (var p in _profileManager.GetProfiles())
foreach (var m in _profileManager.GetMappings(p.Id)) foreach (var m in _profileManager.GetMappings(p.Id))
if (!string.IsNullOrEmpty(m.StarfaceId)) if (!string.IsNullOrEmpty(m.StarfaceId) && !map.ContainsKey(m.StarfaceId))
ids.Add(m.StarfaceId); map[m.StarfaceId] = m.LastOutlook;
} }
catch { } catch { }
return ids; return map;
} }
private void SetStatus(string text) private void SetStatus(string text)