Compare commits

...

9 Commits

Author SHA1 Message Date
duffyduck b8a7fda7bf Release v0.0.0.28 2026-06-08 13:02:51 +02:00
duffyduck 257b6fb33d 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>
2026-06-08 13:01:02 +02:00
duffyduck cf2970a41a 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>
2026-06-08 12:54:01 +02:00
duffyduck 5a0203e49e Document multi-client sync lock in README
Beschreibt Lock-Datei starface-sync.lock: atomares Anlegen (CreateNew),
nur waehrend des Syncs vorhanden, Inhalt (Host/User/Zeit/PID), Warte- und
Absturz-Verhalten, sowie den Hinweis auf bidirektionalen Modus im Mehrplatz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:47:28 +02:00
duffyduck 31aef01ccd Release v0.0.0.27 2026-06-08 12:41:47 +02:00
duffyduck 6c9721acc6 Support UNC paths for shared lock directory; create it if missing
Das gemeinsame Verzeichnis darf ein UNC-Pfad sein (\\server\freigabe\...),
kein Netzlaufwerksbuchstabe noetig. Statt nur Directory.Exists zu pruefen
(und sonst still ohne Sperre zu syncen) wird das Verzeichnis bei Bedarf
angelegt; nur bei echtem Zugriffsfehler wird ohne Sperre fortgefahren.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:40:31 +02:00
duffyduck 212ced4c81 Add cross-client sync lock via shared directory
Verhindert, dass mehrere Arbeitsplaetze gleichzeitig dasselbe Starface-
Adressbuch synchronisieren (Dubletten/Lost-Updates bei echter Ueberlappung).

- Neues optionales Setting "Gemeinsames Verzeichnis" (UserSettings.SharedDirectory)
  in der Einstellungen-Maske inkl. Ordner-Browser.
- SyncLock: atomare Lock-Datei (FileMode.CreateNew) im gemeinsamen Verzeichnis,
  waehrend des Syncs offen gehalten -> bei Absturz gibt das OS das Handle frei
  und ein anderer Client uebernimmt die verwaiste Datei (Stale-Erkennung 15 Min,
  Loeschen scheitert am offenen Handle eines lebenden Halters).
- MainForm wartet vor dem Sync bis zu 2 Min auf eine freie Sperre, sonst wird
  der Lauf uebersprungen. Ohne/bei nicht erreichbarem Verzeichnis laeuft der
  Sync ohne diese Sperre weiter (lokaler Interlocked-Schutz bleibt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:38:45 +02:00
duffyduck d3fa452504 Add field-level 3-way merge for bidirectional conflicts
Bisher wurde bei einem Konflikt (beide Seiten geaendert) der ganze Datensatz
ueberschrieben - eine gleichzeitige Aenderung an einem anderen Feld ging
verloren (z.B. A aendert Telefon in Outlook, B aendert Mail in Starface ->
eine Aenderung weg).

Jetzt:
- Mapping speichert je Seite einen Snapshot des letzten Sync-Stands
  (LastOutlook/LastStarface), zusaetzlich zu den Hashes.
- Bei beidseitiger Aenderung im Both-Modus wird feldweise gemergt
  (ContactMerger): unterschiedliche Felder bleiben beide erhalten, nur bei
  echtem Konflikt am selben Feld gewinnt Outlook.
- Echte Feld-Konflikte landen in SyncResult.Conflicts und werden im MainForm
  per Tray-Meldung angezeigt.
- Snapshots werden in allen Baseline-Punkten gesetzt (Phase 1-3) und fuer
  aeltere Mappings beim naechsten unveraenderten Sync nachgetragen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:35:10 +02:00
duffyduck bee17a7fc6 Make local sync re-entrancy guard atomic
Der Schutz gegen gleichzeitige Syncs (manuell vs. Auto-Sync-Timer) war ein
nicht-atomares pruefen-und-setzen auf einem volatile bool. Zwischen Pruefung
und Setzen konnten ein UI-Klick und der Timer-Thread beide durchrutschen und
zwei Syncs gleichzeitig starten.

Jetzt per Interlocked.CompareExchange atomar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:14:23 +02:00
14 changed files with 826 additions and 28 deletions
+30
View File
@@ -36,8 +36,38 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`).
wurde. Jede Seite hat jetzt eine eigene Baseline (`LastOutlookHash` / wurde. Jede Seite hat jetzt eine eigene Baseline (`LastOutlookHash` /
`LastStarfaceHash`); nur tatsaechlich geaenderte Kontakte werden geschrieben. `LastStarfaceHash`); nur tatsaechlich geaenderte Kontakte werden geschrieben.
### Hinzugefuegt
- **Clientuebergreifende Konflikt-Hinweise (Mehrplatz).** Wird ein echter
Feld-Konflikt aufgeloest, legt der Client eine Notiz im gemeinsamen Verzeichnis
(`conflicts/`) ab - zugeordnet nach Kontakt (StarfaceId). Ein anderer
Arbeitsplatz zeigt die Meldung beim naechsten Sync nur an, wenn er den Kontakt
selbst hat UND sein eigener Feldwert vom uebernommenen Wert abweicht - so wird
der ueberschriebene Arbeitsplatz gewarnt, waehrend bereits aktuelle Clients
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
sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein
Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere
Clients warten, bis sie frei ist (bis 2 Min, sonst wird der Lauf uebersprungen).
Stuerzt ein Client ab, uebernimmt ein anderer die verwaiste Sperre. Ist kein
Verzeichnis konfiguriert oder nicht erreichbar, wird ohne diese Sperre
weitergearbeitet (der lokale Schutz auf dem PC bleibt).
- **Feldweises 3-Wege-Merge bei Konflikten (bidirektional).** Wenn derselbe
Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt
Aenderungen an *unterschiedlichen* Feldern beide erhalten (z.B. einer aendert
die Telefonnummer in Outlook, ein anderer die E-Mail in Starface). Nur wenn
DASSELBE Feld auf beiden Seiten unterschiedlich geaendert wurde, greift die
Vorrang-Regel (Outlook gewinnt). Dafuer wird im Mapping zusaetzlich ein
Snapshot des letzten Sync-Stands je Seite gespeichert. Solche echten
Feld-Konflikte werden dem Benutzer per Tray-Meldung angezeigt.
### Geaendert ### Geaendert
- **Doppelte Syncs verhindert (lokal).** Der Schutz gegen gleichzeitig laufende
Syncs (manuell + Auto-Sync-Timer) ist jetzt atomar (`Interlocked`) statt eines
nicht-atomaren `volatile bool`, bei dem beide in einem Zeitfenster
durchrutschen konnten.
- **Ein-Richtungs-Modi sind jetzt echtes "Ersetzen".** Outlook->Starface macht - **Ein-Richtungs-Modi sind jetzt echtes "Ersetzen".** Outlook->Starface macht
das Starface-Adressbuch zu einer exakten Kopie von Outlook: Kontakte, die nur das Starface-Adressbuch zu einer exakten Kopie von Outlook: Kontakte, die nur
in Starface existieren (kein Pendant in Outlook), werden geloescht. in Starface existieren (kein Pendant in Outlook), werden geloescht.
+47
View File
@@ -265,6 +265,53 @@ Alle Daten werden lokal pro Benutzer gespeichert:
Self-signed Zertifikate der Starface werden automatisch akzeptiert. Es ist kein manueller Zertifikat-Import erforderlich. Self-signed Zertifikate der Starface werden automatisch akzeptiert. Es ist kein manueller Zertifikat-Import erforderlich.
### Mehrplatz-Betrieb / Sync-Sperre
Mehrere Arbeitsplaetze koennen dasselbe Starface-Adressbuch pflegen. Damit sich
gleichzeitig laufende Syncs nicht ins Gehege kommen (Dubletten, ueberschriebene
Aenderungen), gibt es zwei Schutzebenen:
**1. Lokal (immer aktiv):** Auf einem PC kann nie mehr als ein Sync gleichzeitig
laufen. Startet der Auto-Sync-Timer, waehrend ein manueller Sync laeuft (oder
umgekehrt), wird der zweite Lauf uebersprungen. Atomar per `Interlocked`.
**2. Clientuebergreifend (optional):** In den Einstellungen kann ein
**gemeinsames Verzeichnis** (Netzlaufwerk oder UNC-Pfad wie
`\\server\freigabe\sync`) hinterlegt werden. Funktionsweise:
- Beim Start eines Syncs legt der Client dort die Datei **`starface-sync.lock`**
an - **atomar** ueber `FileMode.CreateNew`: das Anlegen schlaegt fehl, wenn
die Datei bereits existiert. Damit kann immer nur ein Client gleichzeitig die
Sperre halten.
- Die Datei wird waehrend des gesamten Syncs **offen gehalten** und am Ende
wieder **geloescht**. Sie existiert also nur, solange tatsaechlich gerade
synchronisiert wird (bei kleinen Adressbuechern nur sehr kurz).
- Inhalt der Datei: `Rechnername | Benutzer | UTC-Zeitstempel | Prozess-ID` -
damit man sieht, wer gerade synct.
- Findet ein anderer Client die Sperre vor, **wartet** er (Abfrage alle 3
Sekunden, bis zu 2 Minuten) und synct erst danach. Wird die Sperre nicht frei,
wird der Lauf uebersprungen und beim naechsten Intervall erneut versucht.
- **Absturzsicher:** Stuerzt ein Client mitten im Sync ab, gibt Windows das
Datei-Handle frei. Die verwaiste Lock-Datei gilt nach 15 Minuten als veraltet
und wird vom naechsten Client uebernommen. Lebt der Halter noch, scheitert das
Loeschen am offenen Handle - die Sperre bleibt also korrekt bestehen.
- 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. Beim naechsten
Sync zeigt ein anderer Arbeitsplatz die Meldung als Tray-Hinweis - aber **nur,
wenn er den Kontakt selbst hat UND sein eigener Feldwert vom uebernommenen Wert
abweicht** (so erfaehrt der ueberschriebene Arbeitsplatz davon, waehrend Clients,
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
> verwendet werden. Die Ein-Richtungs-Modi ("Ersetzen") wuerden die von anderen
> Arbeitsplaetzen gepflegten Kontakte loeschen.
## Lizenz ## Lizenz
Proprietaer - Alle Rechte vorbehalten. Proprietaer - Alle Rechte vorbehalten.
+1 -1
View File
@@ -2,7 +2,7 @@
; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php) ; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
#define MyAppName "Starface Outlook Sync" #define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.26" #define MyAppVersion "0.0.0.28"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation" #define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de" #define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe" #define MyAppExeName "StarfaceOutlookSync.exe"
@@ -0,0 +1,26 @@
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 FieldKey { 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";
}
}
@@ -53,6 +53,13 @@ namespace StarfaceOutlookSync.Models
public string LastOutlookHash { get; set; } = ""; public string LastOutlookHash { get; set; } = "";
public string LastStarfaceHash { get; set; } = ""; public string LastStarfaceHash { get; set; } = "";
// Snapshot des zuletzt synchronisierten Stands je Seite. Ermoeglicht ein
// feldweises 3-Wege-Merge bei Konflikten (welche Seite hat welches Feld
// geaendert), statt den ganzen Datensatz zu ueberschreiben. Null bei
// Alt-Mappings -> dann Fallback auf ganz-ueberschreiben.
public UnifiedContact LastOutlook { get; set; }
public UnifiedContact LastStarface { get; set; }
// Alt-Feld (vor v0.0.0.24). Nur noch fuer Migration bestehender Mappings. // Alt-Feld (vor v0.0.0.24). Nur noch fuer Migration bestehender Mappings.
public string LastSyncHash { get; set; } = ""; public string LastSyncHash { get; set; } = "";
} }
@@ -65,5 +72,24 @@ namespace StarfaceOutlookSync.Models
public int Updated { get; set; } public int Updated { get; set; }
public int Errors { get; set; } public int Errors { get; set; }
public System.Collections.Generic.List<string> ErrorMessages { get; set; } = new System.Collections.Generic.List<string>(); public System.Collections.Generic.List<string> ErrorMessages { get; set; } = new System.Collections.Generic.List<string>();
// Echte Feld-Konflikte (dasselbe Feld auf beiden Seiten geaendert), die
// ueber die Vorrang-Regel aufgeloest wurden. Fuer Benutzer-Hinweise.
public System.Collections.Generic.List<FieldConflict> Conflicts { get; set; } = new System.Collections.Generic.List<FieldConflict>();
}
public class FieldConflict
{
public string StarfaceId { get; set; } = "";
public string ContactName { 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 StarfaceValue { get; set; } = "";
public string Winner { get; set; } = ""; // "Outlook" oder "Starface"
public override string ToString() =>
$"{ContactName}: Feld '{Field}' auf beiden Seiten geaendert " +
$"(Outlook: '{OutlookValue}' / Starface: '{StarfaceValue}') -> {Winner} uebernommen";
} }
} }
@@ -11,6 +11,11 @@ namespace StarfaceOutlookSync.Models
public bool SyncOnStart { get; set; } = false; public bool SyncOnStart { get; set; } = false;
public bool AutoAcceptOutlookPrompt { get; set; } = false; public bool AutoAcceptOutlookPrompt { get; set; } = false;
// Gemeinsames Verzeichnis (Netzlaufwerk/UNC) fuer die clientuebergreifende
// Sync-Sperre. Leer = keine Sperre (nur lokaler Schutz). Verhindert, dass
// mehrere Arbeitsplaetze gleichzeitig dasselbe Adressbuch synchronisieren.
public string SharedDirectory { get; set; } = "";
private static readonly string SettingsFile = Path.Combine( private static readonly string SettingsFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"StarfaceOutlookSync", "settings.json"); "StarfaceOutlookSync", "settings.json");
@@ -0,0 +1,178 @@
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,
FieldKey = c.FieldKey,
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 und nicht von ihm selbst stammen. Es werden nur die Notizen
/// 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>
public List<ConflictNotice> GetPending(string sharedDir, IDictionary<string, UnifiedContact> myContacts)
{
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;
}
// Habe ich diesen Kontakt ueberhaupt?
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);
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 { }
}
}
}
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Feldweises 3-Wege-Merge zweier Kontaktstaende gegen ihre Baseline.
/// Erlaubt es, dass zwei Stellen unterschiedliche Felder desselben Kontakts
/// aendern, ohne dass eine Aenderung verloren geht. Nur wenn DASSELBE Feld
/// auf beiden Seiten geaendert wurde, greift die Vorrang-Regel.
/// </summary>
public static class ContactMerger
{
private class FieldDef
{
public string Key;
public string Label;
public Func<UnifiedContact, string> Get;
public Action<UnifiedContact, string> Set;
public bool IsPhone;
}
private static readonly FieldDef[] Fields = new[]
{
new FieldDef { Key = "FirstName", Label = "Vorname", Get = c => c.FirstName, Set = (c, v) => c.FirstName = v },
new FieldDef { Key = "LastName", Label = "Nachname", Get = c => c.LastName, Set = (c, v) => c.LastName = v },
new FieldDef { Key = "Company", Label = "Firma", Get = c => c.Company, Set = (c, v) => c.Company = v },
new FieldDef { Key = "JobTitle", Label = "Position", Get = c => c.JobTitle, Set = (c, v) => c.JobTitle = v },
new FieldDef { Key = "Email", Label = "E-Mail", Get = c => c.Email, Set = (c, v) => c.Email = v },
new FieldDef { Key = "EmailSecondary", Label = "E-Mail 2", Get = c => c.EmailSecondary, Set = (c, v) => c.EmailSecondary = v },
new FieldDef { Key = "PhoneWork", Label = "Telefon", Get = c => c.PhoneWork, Set = (c, v) => c.PhoneWork = v, IsPhone = true },
new FieldDef { Key = "PhoneMobile", Label = "Mobil", Get = c => c.PhoneMobile, Set = (c, v) => c.PhoneMobile = v, IsPhone = true },
new FieldDef { Key = "PhoneHome", Label = "Telefon privat", Get = c => c.PhoneHome, Set = (c, v) => c.PhoneHome = v, IsPhone = true },
new FieldDef { Key = "Fax", Label = "Fax", Get = c => c.Fax, Set = (c, v) => c.Fax = v, IsPhone = true },
new FieldDef { Key = "Street", Label = "Strasse", Get = c => c.Street, Set = (c, v) => c.Street = v },
new FieldDef { Key = "City", Label = "Ort", Get = c => c.City, Set = (c, v) => c.City = v },
new FieldDef { Key = "PostalCode", Label = "PLZ", Get = c => c.PostalCode, Set = (c, v) => c.PostalCode = v },
new FieldDef { Key = "State", Label = "Bundesland", Get = c => c.State, Set = (c, v) => c.State = v },
new FieldDef { Key = "Country", Label = "Land", Get = c => c.Country, Set = (c, v) => c.Country = v },
new FieldDef { Key = "Website", Label = "Webseite", Get = c => c.Website, Set = (c, v) => c.Website = v },
new FieldDef { Key = "Notes", Label = "Notizen", Get = c => c.Notes, Set = (c, v) => c.Notes = v },
new FieldDef { Key = "Salutation", Label = "Anrede", Get = c => c.Salutation, Set = (c, v) => c.Salutation = v },
new FieldDef { Key = "Title", Label = "Titel", Get = c => c.Title, Set = (c, v) => c.Title = 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>
/// Fuehrt den Outlook- und Starface-Stand gegen ihre jeweilige Baseline
/// zusammen. outlookWins entscheidet bei echten Feld-Konflikten.
/// </summary>
public static (UnifiedContact merged, List<FieldConflict> conflicts) Merge(
UnifiedContact baseOutlook, UnifiedContact baseStarface,
UnifiedContact outlook, UnifiedContact starface,
bool outlookWins)
{
var merged = new UnifiedContact
{
OutlookEntryId = outlook.OutlookEntryId,
StarfaceId = starface.StarfaceId
};
var conflicts = new List<FieldConflict>();
foreach (var f in Fields)
{
string olv = f.Get(outlook) ?? "";
string sfv = f.Get(starface) ?? "";
string bol = f.Get(baseOutlook) ?? "";
string bsf = f.Get(baseStarface) ?? "";
bool olChanged = !Equal(olv, bol, f.IsPhone);
bool sfChanged = !Equal(sfv, bsf, f.IsPhone);
string chosen;
if (olChanged && !sfChanged)
{
chosen = olv;
}
else if (sfChanged && !olChanged)
{
chosen = sfv;
}
else if (olChanged && sfChanged)
{
if (Equal(olv, sfv, f.IsPhone))
{
// Beide auf denselben Wert geaendert -> kein echter Konflikt.
chosen = olv;
}
else
{
chosen = outlookWins ? olv : sfv;
conflicts.Add(new FieldConflict
{
StarfaceId = starface.StarfaceId,
ContactName = outlook.DisplayName,
Field = f.Label,
FieldKey = f.Key,
OutlookValue = olv,
StarfaceValue = sfv,
Winner = outlookWins ? "Outlook" : "Starface"
});
}
}
else
{
// Keine Seite hat dieses Feld geaendert -> Outlook-Wert behalten.
chosen = olv;
}
f.Set(merged, chosen);
}
return (merged, conflicts);
}
private static bool Equal(string a, string b, bool isPhone)
{
if (isPhone)
return NormalizePhone(a) == NormalizePhone(b);
return string.Equals(a ?? "", b ?? "", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
}
}
+73 -14
View File
@@ -15,6 +15,20 @@ namespace StarfaceOutlookSync.Services
private void Log(string message) => OnProgress?.Invoke(message); private void Log(string message) => OnProgress?.Invoke(message);
/// <summary>
/// Setzt die Baseline eines Mappings auf den uebergebenen Stand beider
/// Seiten (Snapshot + Hash). Der Snapshot wird fuer das Feld-Merge bei
/// kuenftigen Konflikten gebraucht.
/// </summary>
private static void SetBaseline(SyncMapping m, UnifiedContact outlook, UnifiedContact starface)
{
m.LastOutlook = outlook;
m.LastStarface = starface;
m.LastOutlookHash = outlook?.GetHash() ?? "";
m.LastStarfaceHash = starface?.GetHash() ?? "";
m.LastSyncHash = "";
}
/// <summary> /// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste. /// Findet einen passenden Kontakt in der Kandidatenliste.
/// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen /// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
@@ -282,9 +296,7 @@ namespace StarfaceOutlookSync.Services
if (string.IsNullOrEmpty(mapping.LastOutlookHash) && if (string.IsNullOrEmpty(mapping.LastOutlookHash) &&
string.IsNullOrEmpty(mapping.LastStarfaceHash)) string.IsNullOrEmpty(mapping.LastStarfaceHash))
{ {
mapping.LastOutlookHash = olHash; SetBaseline(mapping, oc, sc);
mapping.LastStarfaceHash = sfHash;
mapping.LastSyncHash = "";
newMappings.Add(mapping); newMappings.Add(mapping);
continue; continue;
} }
@@ -292,14 +304,23 @@ namespace StarfaceOutlookSync.Services
bool olChanged = olHash != mapping.LastOutlookHash; bool olChanged = olHash != mapping.LastOutlookHash;
bool sfChanged = sfHash != mapping.LastStarfaceHash; bool sfChanged = sfHash != mapping.LastStarfaceHash;
if (!olChanged && !sfChanged)
{
// Unveraendert. Snapshots aelterer Mappings (nur Hash)
// nachtragen, damit kuenftige Konflikte gemergt werden koennen.
if (mapping.LastOutlook == null || mapping.LastStarface == null)
SetBaseline(mapping, oc, sc);
newMappings.Add(mapping);
continue;
}
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)) if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{ {
// Outlook hat sich geaendert -> Starface updaten // Outlook hat sich geaendert -> Starface updaten
var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook); var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null) if (updated != null)
{ {
mapping.LastOutlookHash = olHash; SetBaseline(mapping, oc, updated);
mapping.LastStarfaceHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}"); Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
} }
@@ -310,22 +331,54 @@ namespace StarfaceOutlookSync.Services
var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null) if (updated != null)
{ {
mapping.LastStarfaceHash = sfHash; SetBaseline(mapping, updated, sc);
mapping.LastOutlookHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}"); Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
} }
} }
else if (olChanged && sfChanged) else if (olChanged && sfChanged)
{ {
// Beide geaendert -> Konflikt, Outlook bevorzugt // Beide Seiten geaendert.
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook) if (profile.SyncDirection == SyncDirection.Both
&& mapping.LastOutlook != null && mapping.LastStarface != null)
{ {
// Feldweises 3-Wege-Merge: unterschiedliche Felder
// bleiben beide erhalten; nur bei gleichem Feld auf
// beiden Seiten gewinnt Outlook.
var (merged, conflicts) = ContactMerger.Merge(
mapping.LastOutlook, mapping.LastStarface, oc, sc, outlookWins: true);
var updatedSf = await starface.UpdateContactAsync(mapping.StarfaceId, merged, profile.StarfaceAddressBook);
var updatedOl = _outlookService.UpdateContact(mapping.OutlookEntryId, merged);
if (updatedSf != null || updatedOl != null)
{
if (updatedOl != null)
{
mapping.LastOutlook = updatedOl;
mapping.LastOutlookHash = updatedOl.GetHash();
}
if (updatedSf != null)
{
mapping.LastStarface = updatedSf;
mapping.LastStarfaceHash = updatedSf.GetHash();
}
mapping.LastSyncHash = "";
result.Updated++;
foreach (var cf in conflicts) result.Conflicts.Add(cf);
Log(conflicts.Count > 0
? $" Beidseitig geaendert, zusammengefuehrt ({conflicts.Count} Feld-Konflikt(e), Outlook gewinnt): {oc.DisplayName}"
: $" Beidseitig geaendert, zusammengefuehrt: {oc.DisplayName}");
}
}
else if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{
// Fallback ohne Snapshot bzw. OutlookToStarface:
// Outlook gewinnt komplett.
var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook); var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null) if (updated != null)
{ {
mapping.LastOutlookHash = olHash; SetBaseline(mapping, oc, updated);
mapping.LastStarfaceHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}"); Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
} }
@@ -335,14 +388,12 @@ namespace StarfaceOutlookSync.Services
var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc); var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null) if (updated != null)
{ {
mapping.LastStarfaceHash = sfHash; SetBaseline(mapping, updated, sc);
mapping.LastOutlookHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}"); Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
} }
} }
} }
// Beide unveraendert -> nichts tun
} }
newMappings.Add(mapping); newMappings.Add(mapping);
@@ -382,6 +433,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId, OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId, StarfaceId = match.StarfaceId,
LastOutlook = oc,
LastStarface = updated,
LastOutlookHash = oc.GetHash(), LastOutlookHash = oc.GetHash(),
LastStarfaceHash = updated.GetHash() LastStarfaceHash = updated.GetHash()
}); });
@@ -403,6 +456,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId, OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId, StarfaceId = created.StarfaceId,
LastOutlook = oc,
LastStarface = created,
LastOutlookHash = oc.GetHash(), LastOutlookHash = oc.GetHash(),
LastStarfaceHash = created.GetHash() LastStarfaceHash = created.GetHash()
}); });
@@ -458,6 +513,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId, OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId, StarfaceId = sc.StarfaceId,
LastStarface = sc,
LastOutlook = updated,
LastStarfaceHash = sc.GetHash(), LastStarfaceHash = sc.GetHash(),
LastOutlookHash = updated.GetHash() LastOutlookHash = updated.GetHash()
}); });
@@ -478,6 +535,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId, OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId, StarfaceId = sc.StarfaceId,
LastStarface = sc,
LastOutlook = created,
LastStarfaceHash = sc.GetHash(), LastStarfaceHash = sc.GetHash(),
LastOutlookHash = created.GetHash() LastOutlookHash = created.GetHash()
}); });
@@ -0,0 +1,120 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Clientuebergreifende Sperre ueber eine Lock-Datei in einem gemeinsamen
/// Verzeichnis (Netzlaufwerk/Share). Verhindert, dass mehrere Arbeitsplaetze
/// gleichzeitig dasselbe Starface-Adressbuch synchronisieren.
///
/// Atomar ueber FileMode.CreateNew: das Anlegen schlaegt fehl, wenn die Datei
/// schon existiert. Die Datei wird waehrend des Syncs offen gehalten
/// (FileShare.None) - stuerzt ein Client ab, gibt das Betriebssystem das
/// Handle frei und ein anderer Client kann die (dann verwaiste) Datei
/// uebernehmen. Eine reine Zeitstempel-Pruefung allein waere nicht sicher.
/// </summary>
public sealed class SyncLock : IDisposable
{
public const string LockFileName = "starface-sync.lock";
// Aelter als das -> als verwaist behandeln und Uebernahme VERSUCHEN.
// Lebt der Eigentuemer noch, schlaegt das Loeschen am offenen Handle fehl,
// die Sperre bleibt also korrekt bestehen.
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(15);
private FileStream _stream;
private readonly string _path;
private SyncLock(string path, FileStream stream)
{
_path = path;
_stream = stream;
}
/// <summary>Sperre, die nichts haelt (wenn kein gemeinsames Verzeichnis konfiguriert/erreichbar ist).</summary>
public static SyncLock NoOp() => new SyncLock(null, null);
/// <summary>
/// Versucht die Sperre zu holen. Gibt null zurueck, wenn gerade ein
/// anderer (lebender) Client synct. heldBy enthaelt - sofern lesbar -
/// Infos zum aktuellen Halter.
/// </summary>
public static SyncLock TryAcquire(string dir, out string heldBy)
{
heldBy = null;
var path = Path.Combine(dir, LockFileName);
var lockObj = TryCreate(path);
if (lockObj != null) return lockObj;
// Existiert bereits. Halter ermitteln und ggf. verwaiste Datei uebernehmen.
heldBy = ReadOwner(path);
if (IsStale(path))
{
try { File.Delete(path); } // scheitert, wenn der Halter noch lebt (Handle offen)
catch { return null; }
return TryCreate(path);
}
return null;
}
private static SyncLock TryCreate(string path)
{
try
{
var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
var info = $"{Environment.MachineName}|{Environment.UserName}|{DateTime.UtcNow:o}|pid{GetPid()}";
var bytes = Encoding.UTF8.GetBytes(info);
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
return new SyncLock(path, fs);
}
catch (IOException) { return null; } // existiert schon
catch (UnauthorizedAccessException) { return null; }
}
private static int GetPid()
{
try { return Process.GetCurrentProcess().Id; } catch { return 0; }
}
private static string ReadOwner(string path)
{
try
{
// Eigene Freigabe, falls der Halter die Datei nur zum Schreiben offen hat.
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var sr = new StreamReader(fs))
{
var raw = sr.ReadToEnd();
var parts = raw.Split('|');
return parts.Length >= 1 ? parts[0] : null;
}
}
catch { return null; }
}
private static bool IsStale(string path)
{
try
{
var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(path);
return age > StaleAfter;
}
catch { return false; }
}
public void Dispose()
{
try { _stream?.Dispose(); } catch { }
_stream = null;
if (!string.IsNullOrEmpty(_path))
{
try { File.Delete(_path); } catch { }
}
}
}
}
@@ -7,9 +7,9 @@
<AssemblyTitle>Starface Outlook Sync</AssemblyTitle> <AssemblyTitle>Starface Outlook Sync</AssemblyTitle>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company> <Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product> <Product>Starface Outlook Sync</Product>
<Version>0.0.0.26</Version> <Version>0.0.0.28</Version>
<AssemblyVersion>0.0.0.26</AssemblyVersion> <AssemblyVersion>0.0.0.28</AssemblyVersion>
<FileVersion>0.0.0.26</FileVersion> <FileVersion>0.0.0.28</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description> <Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright> <Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+1 -1
View File
@@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label var lblVersion = new Label
{ {
Text = "Version 0.0.0.26", Text = "Version 0.0.0.28",
Left = 0, Top = 56, Width = 340, Height = 20, Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter, TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray ForeColor = Color.Gray
+121 -4
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using System.Windows.Forms; using System.Windows.Forms;
@@ -15,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;
@@ -22,7 +24,9 @@ namespace StarfaceOutlookSync.UI
private StatusStrip _statusBar; private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel; private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer; private Timer _autoSyncTimer;
private volatile bool _syncRunning = false; // 0 = frei, 1 = Sync laeuft. Per Interlocked atomar geschaltet, damit
// ein manueller Sync und der Auto-Sync-Timer nicht gleichzeitig starten.
private int _syncRunning = 0;
public MainForm() public MainForm()
{ {
@@ -337,15 +341,31 @@ namespace StarfaceOutlookSync.UI
private async Task RunSync(SyncProfile profile) private async Task RunSync(SyncProfile profile)
{ {
if (_syncRunning) // Atomar pruefen-und-setzen: verhindert, dass manueller Sync und
// Auto-Sync-Timer gleichzeitig denselben/einen Sync starten.
if (Interlocked.CompareExchange(ref _syncRunning, 1, 0) != 0)
{ {
SetStatus("Sync laeuft bereits, bitte warten..."); SetStatus("Sync laeuft bereits, bitte warten...");
return; return;
} }
_syncRunning = true; SyncLock crossLock = null;
var sharedDir = UserSettings.Load().SharedDirectory;
try try
{ {
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
crossLock = await AcquireCrossClientLock(sharedDir);
if (crossLock == null)
{
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
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);
@@ -354,10 +374,25 @@ namespace StarfaceOutlookSync.UI
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert"; var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert";
if (result.Errors > 0) msg += $", {result.Errors} Fehler"; if (result.Errors > 0) msg += $", {result.Errors} Fehler";
if (result.Conflicts.Count > 0) msg += $", {result.Conflicts.Count} Konflikt(e)";
_trayIcon.ShowBalloonTip(3000, "Starface Sync", msg, _trayIcon.ShowBalloonTip(3000, "Starface Sync", msg,
result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info); result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info);
// Echte Feld-Konflikte gesondert melden, damit der Benutzer weiss,
// dass ein Wert ueberschrieben wurde.
if (result.Conflicts.Count > 0)
{
var detail = string.Join("\n", result.Conflicts.Take(5).Select(c => c.ToString()));
if (result.Conflicts.Count > 5)
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);
}
SetStatus(msg); SetStatus(msg);
} }
catch (Exception ex) catch (Exception ex)
@@ -368,10 +403,92 @@ namespace StarfaceOutlookSync.UI
} }
finally finally
{ {
_syncRunning = false; crossLock?.Dispose();
Interlocked.Exchange(ref _syncRunning, 0);
} }
} }
/// <summary>
/// Holt die clientuebergreifende Lock-Datei aus dem konfigurierten
/// gemeinsamen Verzeichnis. Wartet bis zu 2 Minuten, falls ein anderer
/// Arbeitsplatz gerade synct. Gibt null zurueck, wenn die Sperre nicht
/// erlangt werden konnte (-> diesen Lauf ueberspringen).
/// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird
/// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt).
/// </summary>
private async Task<SyncLock> AcquireCrossClientLock(string dir)
{
if (string.IsNullOrWhiteSpace(dir))
return SyncLock.NoOp();
try
{
// Legt das Verzeichnis bei Bedarf an (idempotent). Funktioniert mit
// UNC-Pfaden (\\server\freigabe\...) - kein Netzlaufwerk noetig.
System.IO.Directory.CreateDirectory(dir);
}
catch
{
SetStatus("Gemeinsames Verzeichnis nicht erreichbar - synce ohne Sperre.");
return SyncLock.NoOp();
}
var deadline = DateTime.UtcNow.AddMinutes(2);
int attempt = 0;
while (true)
{
var l = SyncLock.TryAcquire(dir, out var heldBy);
if (l != null) return l;
if (DateTime.UtcNow >= deadline) return null;
attempt++;
SetStatus($"Warte auf anderen Arbeitsplatz ({heldBy ?? "unbekannt"})... ({attempt})");
await Task.Delay(3000);
}
}
/// <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, MyContactsByStarfaceId());
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>
/// 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 map = new Dictionary<string, UnifiedContact>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var p in _profileManager.GetProfiles())
foreach (var m in _profileManager.GetMappings(p.Id))
if (!string.IsNullOrEmpty(m.StarfaceId) && !map.ContainsKey(m.StarfaceId))
map[m.StarfaceId] = m.LastOutlook;
}
catch { }
return map;
}
private void SetStatus(string text) private void SetStatus(string text)
{ {
if (InvokeRequired) if (InvokeRequired)
+49 -5
View File
@@ -7,6 +7,8 @@ namespace StarfaceOutlookSync.UI
public class SettingsForm : Form public class SettingsForm : Form
{ {
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook; private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private TextBox _txtSharedDir;
private Button _btnBrowseShared;
private Button _btnSave, _btnCancel; private Button _btnSave, _btnCancel;
private readonly UserSettings _settings; private readonly UserSettings _settings;
@@ -54,35 +56,77 @@ namespace StarfaceOutlookSync.UI
var lblHint = new Label var lblHint = new Label
{ {
Text = hintText, Text = hintText,
Left = 38, Top = 102, Width = 310, Height = 36, Left = 38, Top = 102, Width = 320, Height = 36,
ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray, ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray,
Font = new Font("Segoe UI", 8) Font = new Font("Segoe UI", 8)
}; };
var lblShared = new Label
{
Text = "Gemeinsames Verzeichnis fuer Sync-Sperre (Mehrplatz, optional):",
Left = 20, Top = 150, AutoSize = true
};
_txtSharedDir = new TextBox
{
Left = 20, Top = 172, Width = 250,
Text = _settings.SharedDirectory
};
_btnBrowseShared = new Button
{
Text = "...", Left = 274, Top = 171, Width = 36, Height = 24
};
_btnBrowseShared.Click += (s, e) => BrowseSharedDir();
var lblSharedHint = new Label
{
Text = "Netzlaufwerk/UNC, das alle Arbeitsplaetze erreichen. Leer = keine\n" +
"clientuebergreifende Sperre (nur Schutz auf diesem PC).",
Left = 20, Top = 198, Width = 330, Height = 32,
ForeColor = Color.Gray, Font = new Font("Segoe UI", 8)
};
_btnSave = new Button _btnSave = new Button
{ {
Text = "Speichern", Left = 95, Top = 170, Width = 85, Height = 28, Text = "Speichern", Left = 95, Top = 240, Width = 85, Height = 28,
DialogResult = DialogResult.None DialogResult = DialogResult.None
}; };
_btnSave.Click += (s, e) => Save(); _btnSave.Click += (s, e) => Save();
_btnCancel = new Button _btnCancel = new Button
{ {
Text = "Abbrechen", Left = 189, Top = 170, Width = 85, Height = 28, Text = "Abbrechen", Left = 189, Top = 240, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel DialogResult = DialogResult.Cancel
}; };
Size = new Size(380, 260); Size = new Size(380, 330);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel }); Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint,
lblShared, _txtSharedDir, _btnBrowseShared, lblSharedHint, _btnSave, _btnCancel });
AcceptButton = _btnSave; AcceptButton = _btnSave;
CancelButton = _btnCancel; CancelButton = _btnCancel;
} }
private void BrowseSharedDir()
{
using (var dlg = new FolderBrowserDialog())
{
dlg.Description = "Gemeinsames Verzeichnis fuer die Sync-Sperre waehlen";
if (!string.IsNullOrWhiteSpace(_txtSharedDir.Text))
{
try { dlg.SelectedPath = _txtSharedDir.Text; } catch { }
}
if (dlg.ShowDialog(this) == DialogResult.OK)
_txtSharedDir.Text = dlg.SelectedPath;
}
}
private void Save() private void Save()
{ {
_settings.StartMinimized = _chkStartMinimized.Checked; _settings.StartMinimized = _chkStartMinimized.Checked;
_settings.SyncOnStart = _chkSyncOnStart.Checked; _settings.SyncOnStart = _chkSyncOnStart.Checked;
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked; _settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
_settings.SharedDirectory = _txtSharedDir.Text.Trim();
_settings.Save(); _settings.Save();
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();