Files
starface-outlook-sync-addin/src/StarfaceOutlookSync/UI/MainForm.cs
T
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

451 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
using Timer = System.Timers.Timer;
namespace StarfaceOutlookSync.UI
{
public class MainForm : Form
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly SyncEngine _syncEngine = new SyncEngine();
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
// 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()
{
InitializeComponent();
SetupTrayIcon();
SetupAutoSync();
RefreshProfileList();
// Einstellungen laden und anwenden
var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
if (settings.StartMinimized)
{
WindowState = FormWindowState.Minimized;
ShowInTaskbar = false;
Visible = false;
}
// Beim Start automatisch synchronisieren
if (settings.SyncOnStart)
{
_ = SyncAllProfiles();
}
}
private async Task SyncAllProfiles()
{
var profiles = _profileManager.GetProfiles().Where(p => p.Enabled).ToList();
foreach (var profile in profiles)
{
await RunSync(profile);
}
}
protected override void SetVisibleCore(bool value)
{
// Beim ersten Anzeigen pruefen ob minimiert gestartet werden soll
if (!IsHandleCreated)
{
var settings = UserSettings.Load();
if (settings.StartMinimized)
{
CreateHandle();
value = false;
}
}
base.SetVisibleCore(value);
}
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(830, 450);
MinimumSize = new Size(830, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
// Profil-Liste
_profileList = new ListView
{
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
GridLines = true,
MultiSelect = false
};
_profileList.Columns.Add("Profil", 150);
_profileList.Columns.Add("Starface", 130);
_profileList.Columns.Add("Outlook-Ordner", 130);
_profileList.Columns.Add("Richtung", 90);
_profileList.Columns.Add("Letzte Sync", 130);
_profileList.DoubleClick += (s, e) => EditProfile();
// Button-Panel
var buttonPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 45,
Padding = new Padding(8, 8, 8, 4),
FlowDirection = FlowDirection.LeftToRight
};
_btnNew = new Button { Text = "Neues Profil", Width = 100, Height = 30 };
_btnNew.Click += (s, e) => NewProfile();
_btnEdit = new Button { Text = "Bearbeiten", Width = 90, Height = 30 };
_btnEdit.Click += (s, e) => EditProfile();
_btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 };
_btnDelete.Click += (s, e) => DeleteProfile();
_btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 };
_btnReset.Click += (s, e) => ResetSync();
_btnSettings = new Button { Text = "Einstellungen", Width = 95, Height = 30 };
_btnSettings.Click += (s, e) => ShowSettings();
_btnInfo = new Button { Text = "Info", Width = 50, Height = 30 };
_btnInfo.Click += (s, e) => ShowAbout();
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
_statusLabel = new ToolStripStatusLabel("Bereit");
_statusBar.Items.Add(_statusLabel);
Controls.Add(_profileList);
Controls.Add(buttonPanel);
Controls.Add(_statusBar);
}
private void SetupTrayIcon()
{
_trayMenu = new ContextMenuStrip();
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = AppIcon.GetSmallIcon(),
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
UpdateTrayMenu();
}
private void UpdateTrayMenu()
{
_trayMenu.Items.Clear();
_trayMenu.Items.Add("Oeffnen", null, (s, e) => ShowMainWindow());
_trayMenu.Items.Add("-");
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled))
{
var profile = p;
_trayMenu.Items.Add($"Sync: {profile.Name}", null, async (s, e) =>
{
await RunSync(profile);
});
}
if (profiles.Any(p => p.Enabled))
_trayMenu.Items.Add("-");
_trayMenu.Items.Add("Ueber", null, (s, e) => ShowAbout());
_trayMenu.Items.Add("Beenden", null, (s, e) => ExitApplication());
}
private void SetupAutoSync()
{
_autoSyncTimer = new Timer(60000); // Jede Minute pruefen
_autoSyncTimer.Elapsed += async (s, e) => await CheckAutoSync();
_autoSyncTimer.Start();
}
private async Task CheckAutoSync()
{
var profiles = _profileManager.GetProfiles();
foreach (var profile in profiles.Where(p => p.Enabled && p.AutoSyncIntervalMinutes > 0))
{
if (string.IsNullOrEmpty(profile.LastSync))
{
await RunSync(profile);
continue;
}
if (DateTime.TryParse(profile.LastSync, out var lastSync))
{
if (DateTime.Now - lastSync > TimeSpan.FromMinutes(profile.AutoSyncIntervalMinutes))
{
await RunSync(profile);
}
}
}
}
private void ShowMainWindow()
{
Show();
WindowState = FormWindowState.Normal;
BringToFront();
RefreshProfileList();
}
private void RefreshProfileList()
{
_profileList.Items.Clear();
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles)
{
var dirText = p.SyncDirection == SyncDirection.Both ? "Bidirektional" :
p.SyncDirection == SyncDirection.OutlookToStarface ? "OL -> SF" : "SF -> OL";
var lastSync = string.IsNullOrEmpty(p.LastSync) ? "Noch nie" :
DateTime.TryParse(p.LastSync, out var dt) ? dt.ToString("dd.MM.yyyy HH:mm") : p.LastSync;
var item = new ListViewItem(new[]
{
p.Name,
$"{p.StarfaceConnection.Host} / {p.StarfaceAddressBook.Name}",
p.OutlookFolderName,
dirText,
lastSync
});
item.Tag = p;
if (!p.Enabled)
item.ForeColor = Color.Gray;
_profileList.Items.Add(item);
}
// Tray-Menu aktualisieren
UpdateTrayMenu();
}
private void NewProfile()
{
using (var editor = new ProfileEditorForm(null))
{
if (editor.ShowDialog(this) == DialogResult.OK)
RefreshProfileList();
}
}
private void EditProfile()
{
if (_profileList.SelectedItems.Count == 0) return;
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
using (var editor = new ProfileEditorForm(profile))
{
if (editor.ShowDialog(this) == DialogResult.OK)
RefreshProfileList();
}
}
private void DeleteProfile()
{
if (_profileList.SelectedItems.Count == 0) return;
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
if (MessageBox.Show($"Profil '{profile.Name}' wirklich loeschen?",
"Profil loeschen", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
_profileManager.DeleteProfile(profile.Id);
RefreshProfileList();
}
}
private void ResetSync()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync Reset",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
var msg = $"Sync-Zuordnungen fuer '{profile.Name}' zuruecksetzen?\n\n" +
"Alle Kontakt-Verknuepfungen werden geloescht.\n" +
"Beim naechsten Sync werden die Kontakte neu abgeglichen.\n" +
"Es werden keine Kontakte geloescht.";
if (MessageBox.Show(msg, "Sync Reset",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
{
_profileManager.SaveMappings(profile.Id, new List<SyncMapping>());
// LastSync auch zuruecksetzen
profile.LastSync = "";
_profileManager.UpdateProfile(profile);
RefreshProfileList();
MessageBox.Show("Sync-Zuordnungen wurden zurueckgesetzt.",
"Sync Reset", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private Task SyncSelectedProfile()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return Task.CompletedTask;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return Task.CompletedTask;
using (var syncForm = new SyncProgressForm(profile))
{
syncForm.ShowDialog(this);
}
RefreshProfileList();
return Task.CompletedTask;
}
private async Task RunSync(SyncProfile profile)
{
// 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...");
return;
}
try
{
SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
var result = await _syncEngine.SyncProfileAsync(profile);
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert";
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,
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);
}
SetStatus(msg);
}
catch (Exception ex)
{
_trayIcon.ShowBalloonTip(3000, "Starface Sync Fehler",
ex.Message, ToolTipIcon.Error);
SetStatus($"Fehler: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _syncRunning, 0);
}
}
private void SetStatus(string text)
{
if (InvokeRequired)
Invoke(new Action(() => _statusLabel.Text = text));
else
_statusLabel.Text = text;
}
private void ShowSettings()
{
using (var settings = new SettingsForm())
{
settings.ShowDialog(this);
}
}
private void ShowAbout()
{
using (var about = new AboutForm())
{
about.ShowDialog(this);
}
}
private void ExitApplication()
{
_autoSyncTimer?.Stop();
_trayIcon.Visible = false;
_trayIcon.Dispose();
Application.Exit();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide(); // In den Tray minimieren
_trayIcon.ShowBalloonTip(2000, "Starface Kontakt-Sync",
"Laeuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon fuer Optionen.",
ToolTipIcon.Info);
}
else
{
base.OnFormClosing(e);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_autoSyncTimer?.Dispose();
_trayIcon?.Dispose();
_trayMenu?.Dispose();
}
base.Dispose(disposing);
}
}
}