Compare commits

..
9 Commits
Author SHA1 Message Date
duffyduck ba0b96f16a Release v0.0.3.3 2026-08-17 14:16:51 +02:00
duffyduckandClaude Opus 4.8 e7a87fbdf1 Fix DedupeForm crash when closed during run (ObjectDisposedException)
Wurde das Dubletten-Fenster geschlossen, waehrend Analyse/Zusammenfuehrung
noch lief, griff die Hintergrund-Fortsetzung auf das entsorgte Textfeld zu
-> ObjectDisposedException im async-void Handler -> App-Absturz.

- AppendLog ist jetzt disposed-sicher (IsDisposed/Disposing-Check, BeginInvoke,
  ObjectDisposedException/InvalidOperationException abgefangen).
- OnFormClosing verhindert das Schliessen, solange _busy (Analyse/Merge laeuft),
  sodass keine Fortsetzung auf entsorgte Controls trifft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 14:16:23 +02:00
duffyduckandClaude Opus 4.8 0076d84062 Make dedupe robust: independent sides, wait for sync, report failures
Nach der Vorschau blieben Dubletten teils stehen. Ursachen abgesichert:

- Outlook- und Anlagen-Seite laufen jetzt in getrennten try-Bloecken. Vorher
  brach ein Fehler beim Laden/Verarbeiten der Anlage die ganze Methode ab,
  bevor die Outlook-Seite ueberhaupt bereinigt wurde.
- RunExclusiveAsync wartet bis zu 60s auf einen laufenden Sync, statt sofort
  abzubrechen (Auto-Sync feuert jede Minute -> haeufige Kollision).
- Fehlgeschlagene Loeschungen werden jetzt als Fehler gemeldet (vorher still
  verschluckt), plus Gruppen-Zaehler ins Log.
- DedupeForm zeigt einen deutlichen Hinweis (MessageBox), wenn wegen eines
  laufenden Syncs nicht ausgefuehrt wurde, und laesst einen erneuten Versuch zu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 14:04:49 +02:00
duffyduck 6cc3103040 Release v0.0.3.2 2026-08-17 13:30:59 +02:00
duffyduckandClaude Opus 4.8 844094d5c5 Keep UI responsive during sync (run engine off the UI thread)
Beim Tray-/Auto-Sync lief die Engine auf dem UI-Thread -> Fenster und
Tray-Kontextmenue froren ein (Menuepunkte reagierten nicht).

- MainForm.RunSync fuehrt die Engine jetzt via Task.Run im Hintergrund aus
  (wie schon der manuelle Fenster-Sync).
- Balloon() marshalled Tray-Meldungen thread-sicher per BeginInvoke auf den
  UI-Thread (die Fortsetzung kann nach Task.Run aus einem Pool-Thread kommen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 13:29:49 +02:00
duffyduck da20d341d7 Release v0.0.3.1 2026-08-17 13:19:07 +02:00
duffyduckandClaude Opus 4.8 ca05af75e3 Add "Dubletten zusammenfuehren" (merge duplicates) per profile
Neuer Button "Dubletten" im Hauptfenster fuehrt doppelte Kontakte fuer das
ausgewaehlte Profil zusammen - je Seite (Telefonanlage + Outlook) getrennt.

- Matching in ContactMatcher extrahiert (Sync UND Dedupe nutzen exakt dieselbe
  Erkennung).
- ContactMerger.FillEmptyInto / FilledFieldCount: leere Felder des Gewinners
  auffuellen (kein Datenverlust), Gewinner-Wahl nach Zuordnung/Vollstaendigkeit.
- DedupeService: Analyse (Vorschau) + Ausfuehrung (Merge, Loeschen der
  Dubletten, Bereinigen verwaister Zuordnungen).
- SyncCoordinator.RunExclusiveAsync: Dedupe laeuft unter demselben Guard +
  Lock-Datei wie ein Sync (nie gleichzeitig; kein Konflikt zwischen Clients).
- DedupeForm: Vorschau mit optionaler Detailliste, Bestaetigung, Live-Log;
  danach Hinweis, einmal zu synchronisieren.
- MainForm: Button "Dubletten" (Profil-Auswahl noetig), Fenster verbreitert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 13:11:16 +02:00
duffyduckandClaude Opus 4.8 9767299edd Fix duplicate company/service contacts: robust phone matching
Firmen-/Service-Eintraege (nur Nummer + Firma, kein Name/E-Mail) wurden bei
jedem Sync neu angelegt statt verknuepft, weil das Matching Rufnummern
feldgenau und formatabhaengig verglich. Bei unterschiedlich formatierten
Nummern (+49 vs 0) und wechselnden Feldern (geschaeftlich/privat) schlug der
Abgleich fehl -> Vervielfachung.

- NormalizePhone vereinheitlicht jetzt +49/0049 -> 0 (SyncEngine + ContactMerger).
- Telefon-Abgleich feldUEBERGREIFEND (jede Nummer gegen jede) via SharedPhone.
- "Nummer + Firma"-Treffer nur noch fuer Eintraege OHNE Personennamen, damit
  Kollegen mit gemeinsamer Zentrale-Nummer nicht verschmolzen werden.
- Zusaetzlich: reine Nummern-Eintraege (kein Name/Firma/E-Mail) matchen ueber
  eine gemeinsame Rufnummer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 12:59:54 +02:00
duffyduckandClaude Opus 5 6ecd5d7fa3 Fonaria als zweite Anlage neben Starface
Die SyncEngine kennt jetzt nur noch IContactBackend. Was dahinter steckt,
entscheidet das Profil — der Ablauf mit getrennten Baselines, Wiederzuordnung
und Konfliktaufloesung bleibt unangetastet. In ihm steckt die Erfahrung, die
man nicht zweimal machen will.

FonariaApiClient spricht deren REST-API. Der Kern ist das Feld `verwaltet`,
das jeder Schreibvorgang mitschickt: Fonaria haelt den Kontakt als
vollstaendige vCard, dieses Sync-Modell bildet davon nur einen Ausschnitt ab.
Ohne diese Liste haette ein aus Outlook gespiegelter Kontakt nach dem ersten
Abgleich kein Bild, keine Kategorien und keinen Jahrestag mehr — nicht weil
jemand sie geloescht haette, sondern weil wir sie nicht kannten.

Zwei Verluste lagen darunter und sind gefunden worden, weil der Client gegen
eine echte Anlage lief:

- Die Abteilung steckt im zweiten Teil von ORG. Wer nur die Firma schickt,
  loescht sie mit.
- Neben der geschaeftlichen Anschrift kann eine private stehen. Das
  Sync-Modell traegt nur eine.

Beides wird jetzt aus dem gelesenen Stand unveraendert zurueckgeschrieben.

Umbenannt: StarfaceConnection -> SystemConnection, StarfaceAddressBook ->
RemoteAddressBook. Die JSON-Namen bleiben ueber JsonProperty erhalten, sonst
verloeren bestehende Profile ihre Verbindungsdaten. SyncMapping.StarfaceId
behaelt seinen Namen aus demselben Grund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:10:43 +02:00
18 changed files with 1372 additions and 132 deletions
+30
View File
@@ -7,8 +7,38 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`).
## [Unreleased]
### Hinzugefuegt
- **Dubletten zusammenfuehren (Button "Dubletten").** Fuehrt doppelte Kontakte
fuer das ausgewaehlte Profil zusammen - getrennt je Seite (Telefonanlage und
Outlook). Zuerst wird analysiert und eine Vorschau (optional mit Detailliste)
gezeigt; nach Bestaetigung bleibt je Gruppe ein "Gewinner", dessen leere
Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die uebrigen
werden geloescht. Verwaiste Zuordnungen werden bereinigt, sodass der naechste
bidirektionale Sync die Gewinner sauber 1:1 verbindet. Laeuft unter derselben
Sperre wie ein Sync (kein gleichzeitiger Lauf - wartet bei laufendem Sync bis
zu 60s statt abzubrechen), nutzt dieselbe Kontakt-Erkennung wie der Sync und
wird vollstaendig protokolliert. Outlook- und Anlagen-Seite werden unabhaengig
verarbeitet (eine streikende Anlage verhindert nicht die Outlook-Bereinigung);
fehlgeschlagene Loeschungen werden gemeldet.
### Behoben
- **Oberflaeche friert waehrend des Syncs nicht mehr ein.** Beim Tray-/Auto-Sync
lief die Sync-Engine auf dem UI-Thread, wodurch Fenster und Tray-Kontextmenue
blockierten (Menuepunkte reagierten nicht). Die Engine laeuft jetzt im
Hintergrund (`Task.Run`), die Tray-Meldungen werden thread-sicher auf den
UI-Thread marshalled.
- **Dubletten bei Firmen-/Service-Eintraegen (nur Nummer + Firma).** Solche
Eintraege wurden bei jedem Sync neu angelegt statt verknuepft, weil der
Nummern-Abgleich feldgenau (geschaeftlich↔geschaeftlich) und formatabhaengig
war. Jetzt werden Rufnummern feldUEBERGREIFEND und formatunabhaengig
verglichen (`+49`/`0049` == `0`), sodass diese Eintraege verknuepft werden.
Der "Nummer + Firma"-Abgleich greift bewusst nur bei Eintraegen OHNE
Personennamen, damit keine zwei Kollegen mit gleicher Zentrale-Nummer
verschmolzen werden. (Bereits entstandene Dubletten muessen einmalig manuell
bereinigt werden.)
- **Dubletten auf beiden Seiten beim Synchronisieren.** Mehrere zusammenhaengende
Ursachen wurden beseitigt:
- Eine unvollstaendig geladene Starface-Kontaktliste (z.B. durch einen
+1 -1
View File
@@ -2,7 +2,7 @@
; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
#define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.30"
#define MyAppVersion "0.0.3.3"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"
+35 -4
View File
@@ -1,5 +1,14 @@
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
{
/// <summary>Welche Telefonanlage auf der anderen Seite steht.</summary>
public enum PhoneSystem
{
Starface,
Fonaria
}
public enum SyncDirection
{
Both,
@@ -7,8 +16,12 @@ namespace StarfaceOutlookSync.Models
StarfaceToOutlook
}
public class StarfaceConnection
public class SystemConnection
{
// Voreinstellung Starface, damit Profile aus der Zeit vor Fonaria
// unveraendert weiterlaufen: Was im JSON fehlt, ist eine Starface.
public PhoneSystem System { get; set; } = PhoneSystem.Starface;
public string Host { get; set; } = "";
public int Port { get; set; } = 443;
public bool UseSsl { get; set; } = true;
@@ -16,7 +29,16 @@ namespace StarfaceOutlookSync.Models
public string Password { get; set; } = "";
}
public class StarfaceAddressBook
/// <summary>
/// Ein Adressbuch auf der Gegenseite.
///
/// Die Felder heissen noch nach der Starface, weil dort ihre Bedeutung
/// herkommt: <c>Type</c> unterscheidet zentrales, persoenliches und
/// Tag-Adressbuch, <c>TagId</c> traegt die Kennung. Bei Fonaria steht in
/// <c>TagId</c> schlicht die Nummer des Adressbuchs und in <c>Type</c>
/// dessen Art (private, shared, system).
/// </summary>
public class RemoteAddressBook
{
public string Type { get; set; } = "central"; // central, user, tag
public string UserId { get; set; } = "";
@@ -30,8 +52,13 @@ namespace StarfaceOutlookSync.Models
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StarfaceConnection StarfaceConnection { get; set; } = new StarfaceConnection();
public StarfaceAddressBook StarfaceAddressBook { get; set; } = new StarfaceAddressBook();
// Die JSON-Namen bleiben, wie sie waren — sonst verloeren alle
// bestehenden Profile beim ersten Start ihre Verbindungsdaten.
[JsonProperty("StarfaceConnection")]
public SystemConnection Connection { get; set; } = new SystemConnection();
[JsonProperty("StarfaceAddressBook")]
public RemoteAddressBook AddressBook { get; set; } = new RemoteAddressBook();
public string OutlookFolderPath { get; set; } = "";
public string OutlookFolderName { get; set; } = "Kontakte";
public SyncDirection SyncDirection { get; set; } = SyncDirection.Both;
@@ -44,6 +71,10 @@ namespace StarfaceOutlookSync.Models
{
public string ProfileId { get; set; } = "";
public string OutlookEntryId { get; set; } = "";
// Die Kennung auf der Gegenseite — bei Fonaria die Kontakt-ID. Der
// Name bleibt: Er steht in jeder bereits gespeicherten Zuordnung, und
// ein Umbenennen wuerde sie alle entwerten.
public string StarfaceId { get; set; } = "";
// Getrennte Baselines pro Seite. Outlook und Starface stellen denselben
@@ -2,6 +2,22 @@ namespace StarfaceOutlookSync.Models
{
public class UnifiedContact
{
/// <summary>
/// Welche vCard-Eigenschaften dieses Modell abbildet.
///
/// Fonaria fuehrt den Kontakt im vollen vCard-Umfang — Kategorien,
/// Jahrestag, Spitzname, Bild, zweite Anschrift. Hier steht davon nur
/// ein Ausschnitt, weil die Starface nicht mehr kann. Beim Schreiben
/// nach Fonaria wird diese Liste mitgeschickt: Sie sagt der Anlage,
/// wofuer wir zustaendig sind. Alles andere bleibt dort unangetastet,
/// statt geloescht zu werden, nur weil wir es nicht kennen.
/// </summary>
public static readonly string[] VcardFelder =
{
"FN", "N", "ORG", "TITLE", "TEL", "EMAIL", "ADR", "URL", "NOTE", "BDAY"
};
public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string FirstName { get; set; } = "";
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Erkennt, ob zwei Kontakte dieselbe Person/Firma sind. Wird sowohl vom
/// Sync (Wiederzuordnung, Duplikat-Vermeidung) als auch von der
/// Dubletten-Zusammenfuehrung genutzt - beide MUESSEN dieselbe Logik
/// verwenden, sonst laufen sie gegeneinander.
/// </summary>
public static class ContactMatcher
{
public static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
foreach (var c in candidates)
if (IsMatch(contact, c))
return c;
return null;
}
public static bool IsMatch(UnifiedContact a, UnifiedContact b)
{
bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
&& (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase)
&& (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase);
// Telefon feldUEBERGREIFEND und formatunabhaengig (+49 == 0).
bool phoneMatch = SharedPhone(a, b);
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
if (emailMatch) return true;
if (nameMatch && !emailContradiction) return true;
// Firma-/Service-Eintraege OHNE Personennamen: gleiche Firma + gemeinsame
// Nummer. Bewusst nur ohne Namen, damit nicht zwei Kollegen mit gleicher
// Zentrale-Nummer + Firma faelschlich verschmolzen werden.
bool aHasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName);
bool bHasName = !string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName);
if (phoneMatch && companyMatch && !aHasName && !bHasName && !emailContradiction) return true;
// Reine Nummern-Eintraege: eine gemeinsame Rufnummer identifiziert den Eintrag.
if (phoneMatch && IsBareNumberEntry(a) && IsBareNumberEntry(b)) return true;
return false;
}
private static bool IsBareNumberEntry(UnifiedContact c) =>
string.IsNullOrEmpty(c.FirstName) && string.IsNullOrEmpty(c.LastName)
&& string.IsNullOrEmpty(c.Company) && string.IsNullOrEmpty(c.Email);
/// <summary>True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben.</summary>
public static bool SharedPhone(UnifiedContact a, UnifiedContact b)
{
var pa = PhoneSet(a);
if (pa.Count == 0) return false;
var pb = PhoneSet(b);
return pb.Count > 0 && pa.Overlaps(pb);
}
private static HashSet<string> PhoneSet(UnifiedContact c)
{
var set = new HashSet<string>();
foreach (var p in new[] { c.PhoneWork, c.PhoneMobile, c.PhoneHome, c.Fax })
{
var n = NormalizePhone(p);
if (n.Length >= 5) set.Add(n); // zu kurze (z.B. reine Durchwahlen) ignorieren
}
return set;
}
public static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
if (s.StartsWith("+49")) s = "0" + s.Substring(3);
else if (s.StartsWith("0049")) s = "0" + s.Substring(4);
else if (s.StartsWith("+")) s = s.Substring(1);
return s;
}
}
}
@@ -46,6 +46,34 @@ namespace StarfaceOutlookSync.Services
new FieldDef { Key = "Birthday", Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v },
};
/// <summary>
/// Fuellt LEERE Felder von target aus den sources auf (erste nicht-leere
/// Quelle gewinnt). Vorhandene Werte von target bleiben unveraendert -
/// kein Datenverlust. Gibt true zurueck, wenn sich etwas geaendert hat.
/// </summary>
public static bool FillEmptyInto(UnifiedContact target, IEnumerable<UnifiedContact> sources)
{
bool changed = false;
var list = sources?.ToList() ?? new List<UnifiedContact>();
foreach (var f in Fields)
{
if (!string.IsNullOrEmpty(f.Get(target))) continue;
foreach (var s in list)
{
var v = f.Get(s);
if (!string.IsNullOrEmpty(v)) { f.Set(target, v); changed = true; break; }
}
}
return changed;
}
/// <summary>Anzahl gefuellter Inhaltsfelder - fuer die Wahl des "Gewinners".</summary>
public static int FilledFieldCount(UnifiedContact c)
{
if (c == null) return 0;
return Fields.Count(f => !string.IsNullOrEmpty(f.Get(c)));
}
/// <summary>Liest den Wert eines Feldes per stabilem Schluessel.</summary>
public static string GetValue(UnifiedContact c, string key)
{
@@ -140,7 +168,11 @@ namespace StarfaceOutlookSync.Services
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
if (s.StartsWith("+49")) s = "0" + s.Substring(3);
else if (s.StartsWith("0049")) s = "0" + s.Substring(4);
else if (s.StartsWith("+")) s = s.Substring(1);
return s;
}
}
}
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Fuehrt Dubletten innerhalb EINES Profils zusammen - getrennt je Seite
/// (Telefonanlage und Outlook). Regel: ein "Gewinner" bleibt, dessen leere
/// Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die
/// uebrigen werden geloescht. Danach werden verwaiste Zuordnungen entfernt,
/// damit der naechste (bidirektionale) Sync die Gewinner sauber 1:1 verbindet.
/// Nutzt dieselbe Erkennung wie der Sync (ContactMatcher).
/// </summary>
public class DedupeService
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly OutlookContactsService _outlookService = new OutlookContactsService();
public event Action<string> OnProgress;
private void Log(string m) => OnProgress?.Invoke(m);
public class DupGroup
{
public UnifiedContact Survivor;
public List<UnifiedContact> Duplicates = new List<UnifiedContact>();
}
public class DedupePlan
{
public List<DupGroup> RemoteGroups = new List<DupGroup>(); // Telefonanlage
public List<DupGroup> OutlookGroups = new List<DupGroup>();
public int RemoteDeletions => RemoteGroups.Sum(g => g.Duplicates.Count);
public int OutlookDeletions => OutlookGroups.Sum(g => g.Duplicates.Count);
public bool HasWork => RemoteGroups.Count > 0 || OutlookGroups.Count > 0;
}
public class DedupeReport
{
public int RemoteMerged, RemoteDeleted, OutlookMerged, OutlookDeleted, Errors;
public List<string> Messages = new List<string>();
}
/// <summary>Nur analysieren (lesend), fuer die Vorschau.</summary>
public async Task<DedupePlan> AnalyzeAsync(SyncProfile profile)
{
var plan = new DedupePlan();
var mappings = _profileManager.GetMappings(profile.Id);
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
var mappedOutlook = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId));
Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
Log($"Verbinde mit {profile.Connection.System}...");
using (var backend = ContactBackendFactory.Create(profile.Connection))
{
backend.OnDebug += Log;
if (!await backend.LoginAsync())
throw new Exception("Login an der Telefonanlage fehlgeschlagen.");
Log("Lade Kontakte der Telefonanlage...");
var remoteContacts = await backend.GetContactsAsync(profile.AddressBook);
await backend.LogoutAsync();
plan.RemoteGroups = GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId));
plan.OutlookGroups = GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId));
}
Log($"Analyse fertig: Anlage {plan.RemoteGroups.Count} Gruppe(n)/{plan.RemoteDeletions} zu loeschen, " +
$"Outlook {plan.OutlookGroups.Count} Gruppe(n)/{plan.OutlookDeletions} zu loeschen.");
return plan;
}
/// <summary>
/// Fuehrt die Zusammenfuehrung wirklich aus. Laedt frisch (unter Sperre
/// aufrufen!), gruppiert und wendet an. Gibt einen Bericht zurueck.
/// </summary>
public async Task<DedupeReport> ExecuteAsync(SyncProfile profile)
{
var report = new DedupeReport();
var mappings = _profileManager.GetMappings(profile.Id);
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
var mappedOutlook = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId));
var deletedStarfaceIds = new HashSet<string>();
var deletedOutlookIds = new HashSet<string>();
// --- Outlook (lokal, unabhaengig - laeuft auch wenn die Anlage streikt) ---
try
{
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
var groups = GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId));
Log($"Outlook: {groups.Count} Gruppe(n) mit Dubletten.");
foreach (var g in groups)
{
try
{
bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates);
if (changed && _outlookService.UpdateContact(g.Survivor.OutlookEntryId, g.Survivor) == null)
{
report.Errors++;
report.Messages.Add($"Outlook: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - uebersprungen.");
continue;
}
foreach (var dup in g.Duplicates)
{
if (_outlookService.DeleteContact(dup.OutlookEntryId))
{
deletedOutlookIds.Add(dup.OutlookEntryId);
report.OutlookDeleted++;
Log($" Outlook: Dublette geloescht -> '{g.Survivor.DisplayName}'");
}
else
{
report.Errors++;
report.Messages.Add($"Outlook: Dublette von '{g.Survivor.DisplayName}' konnte nicht geloescht werden.");
}
}
report.OutlookMerged++;
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add($"Outlook '{g.Survivor.DisplayName}': {ex.Message}");
}
}
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add("Outlook gesamt: " + ex.Message);
}
// --- Telefonanlage (unabhaengig) ---
try
{
using (var backend = ContactBackendFactory.Create(profile.Connection))
{
backend.OnDebug += Log;
if (!await backend.LoginAsync())
throw new Exception("Login an der Telefonanlage fehlgeschlagen.");
var remoteContacts = await backend.GetContactsAsync(profile.AddressBook);
var groups = GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId));
Log($"Telefonanlage: {groups.Count} Gruppe(n) mit Dubletten.");
foreach (var g in groups)
{
try
{
bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates);
if (changed && await backend.UpdateContactAsync(g.Survivor.StarfaceId, g.Survivor, profile.AddressBook) == null)
{
report.Errors++;
report.Messages.Add($"Anlage: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - uebersprungen.");
continue;
}
foreach (var dup in g.Duplicates)
{
if (await backend.DeleteContactAsync(dup.StarfaceId))
{
deletedStarfaceIds.Add(dup.StarfaceId);
report.RemoteDeleted++;
Log($" Anlage: Dublette geloescht -> '{g.Survivor.DisplayName}'");
}
else
{
report.Errors++;
report.Messages.Add($"Anlage: Dublette von '{g.Survivor.DisplayName}' konnte nicht geloescht werden.");
}
}
report.RemoteMerged++;
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add($"Anlage '{g.Survivor.DisplayName}': {ex.Message}");
}
}
await backend.LogoutAsync();
}
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add("Telefonanlage gesamt: " + ex.Message);
}
// Verwaiste Zuordnungen (zeigen auf geloeschte Dubletten) entfernen.
// Der naechste Sync verbindet die Gewinner sauber neu.
if (deletedStarfaceIds.Count > 0 || deletedOutlookIds.Count > 0)
{
var kept = mappings
.Where(m => !deletedStarfaceIds.Contains(m.StarfaceId) && !deletedOutlookIds.Contains(m.OutlookEntryId))
.ToList();
_profileManager.SaveMappings(profile.Id, kept);
}
return report;
}
// ---- Gruppierung ----
private static List<DupGroup> GroupDuplicates(List<UnifiedContact> contacts, Func<UnifiedContact, bool> isMapped)
{
var groups = new List<DupGroup>();
if (contacts == null) return groups;
var used = new bool[contacts.Count];
for (int i = 0; i < contacts.Count; i++)
{
if (used[i]) continue;
var members = new List<UnifiedContact> { contacts[i] };
used[i] = true;
for (int j = i + 1; j < contacts.Count; j++)
{
if (used[j]) continue;
if (members.Any(m => ContactMatcher.IsMatch(m, contacts[j])))
{
members.Add(contacts[j]);
used[j] = true;
}
}
if (members.Count > 1)
{
var survivor = ChooseSurvivor(members, isMapped);
groups.Add(new DupGroup
{
Survivor = survivor,
Duplicates = members.Where(m => !ReferenceEquals(m, survivor)).ToList()
});
}
}
return groups;
}
private static UnifiedContact ChooseSurvivor(List<UnifiedContact> members, Func<UnifiedContact, bool> isMapped)
{
// 1) einen bereits zugeordneten bevorzugen (Kontinuitaet),
// 2) sonst den mit den meisten gefuellten Feldern.
return members
.OrderByDescending(m => isMapped(m) ? 1 : 0)
.ThenByDescending(m => ContactMerger.FilledFieldCount(m))
.First();
}
}
}
@@ -0,0 +1,432 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Die Gegenseite Fonaria.
///
/// Anders als die Starface haelt Fonaria jeden Kontakt als vollstaendige
/// vCard vor — mit Bild, Kategorien, Jahrestag, mehreren Anschriften. Das
/// Sync-Modell dieser Anwendung bildet davon nur einen Ausschnitt ab.
/// Deshalb schickt jeder Schreibvorgang <c>verwaltet</c> mit: die Liste der
/// Eigenschaften, fuer die wir uns zustaendig erklaeren. Die Anlage
/// schreibt genau diese in die vorhandene Karte und laesst den Rest stehen.
///
/// Ohne das waere der erste Abgleich verheerend: Ein aus Outlook
/// gespiegelter Kontakt haette hinterher kein Bild und keine Kategorien
/// mehr — nicht weil jemand sie geloescht haette, sondern weil wir sie
/// nicht kannten.
/// </summary>
public class FonariaApiClient : IContactBackend
{
private readonly HttpClient _http;
private readonly SystemConnection _connection;
private readonly string _baseUrl;
private string _token;
public event Action<string> OnDebug;
public string SystemName => "Fonaria";
public IReadOnlyList<string> VerwalteteFelder => UnifiedContact.VcardFelder;
public FonariaApiClient(SystemConnection connection)
{
_connection = connection;
var handler = new HttpClientHandler();
// Wie bei der Starface: Anlagen im eigenen Netz tragen oft ein
// selbst ausgestelltes Zertifikat.
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;
_http = new HttpClient(handler);
_http.Timeout = TimeSpan.FromSeconds(30);
var protocol = connection.UseSsl ? "https" : "http";
var portPart = (connection.UseSsl && connection.Port == 443) ||
(!connection.UseSsl && connection.Port == 80)
? "" : $":{connection.Port}";
_baseUrl = $"{protocol}://{connection.Host}{portPart}/api/v1";
}
// -------------------------------------------------------------------
// Anmeldung
// -------------------------------------------------------------------
public async Task<bool> LoginAsync()
{
try
{
var body = new { username = _connection.LoginId, password = _connection.Password };
var content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/auth/login", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Fonaria-Anmeldung fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return false;
}
_token = JObject.Parse(respBody)["access_token"]?.ToString();
if (string.IsNullOrEmpty(_token)) return false;
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _token);
return true;
}
catch (Exception ex)
{
OnDebug?.Invoke($"Fonaria-Anmeldung fehlgeschlagen: {ex.Message}");
return false;
}
}
public Task LogoutAsync()
{
// Fonaria-Token laufen von selbst ab; es gibt nichts abzumelden.
_token = null;
_http.DefaultRequestHeaders.Authorization = null;
return Task.CompletedTask;
}
// -------------------------------------------------------------------
// Lesen
// -------------------------------------------------------------------
public async Task<List<RemoteAddressBook>> GetAddressBooksAsync()
{
var buecher = new List<RemoteAddressBook>();
var resp = await _http.GetAsync($"{_baseUrl}/addressbooks");
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Adressbuecher nicht abrufbar: {(int)resp.StatusCode}");
return buecher;
}
foreach (var eintrag in JArray.Parse(await resp.Content.ReadAsStringAsync()))
{
var art = eintrag["kind"]?.ToString() ?? "shared";
// Das Benutzerverzeichnis pflegt die Anlage selbst und weist
// jeden Schreibversuch ab. Als Sync-Ziel waere es eine Falle.
if (art == "system") continue;
buecher.Add(new RemoteAddressBook
{
Type = art,
TagId = eintrag["id"]?.ToString() ?? "",
Name = eintrag["name"]?.ToString() ?? "",
UserId = eintrag["owner_id"]?.ToString() ?? ""
});
}
return buecher;
}
public async Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book)
{
// Bewusst mit full=1: Die Kurzform der Liste traegt nur Name, Firma
// und Nummern. Wer damit abgleicht, haelt jeden Kontakt fuer
// veraendert und schreibt bei jedem Durchlauf alles neu.
var url = $"{_baseUrl}/addressbooks/{book.TagId}/contacts?full=1&limit=5000";
HttpResponseMessage resp = null;
for (var versuch = 0; versuch < 3; versuch++)
{
try
{
resp = await _http.GetAsync(url);
if (resp.IsSuccessStatusCode) break;
}
catch (Exception ex)
{
OnDebug?.Invoke($"Kontaktabruf gescheitert (Versuch {versuch + 1}): {ex.Message}");
}
await Task.Delay(250 * (versuch + 1));
}
// Mit einer halben Liste weiterzuarbeiten ist gefaehrlicher als
// abzubrechen: Fehlende Kontakte gaelten als geloescht.
if (resp == null || !resp.IsSuccessStatusCode)
{
throw new Exception(
"Die Kontakte aus Fonaria konnten nicht geladen werden. " +
"Synchronisation abgebrochen, um Dubletten und Loeschungen zu vermeiden.");
}
var kontakte = new List<UnifiedContact>();
foreach (var eintrag in JArray.Parse(await resp.Content.ReadAsStringAsync()))
kontakte.Add(AusFonaria(eintrag));
OnDebug?.Invoke($"{kontakte.Count} Kontakte aus Fonaria-Adressbuch {book.Name} geladen");
return kontakte;
}
public async Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book)
{
try
{
var resp = await _http.GetAsync(
$"{_baseUrl}/addressbooks/{book.TagId}/contacts/{contactId}");
if (resp.StatusCode == HttpStatusCode.NotFound) return null;
if (!resp.IsSuccessStatusCode) return null;
return AusFonaria(JObject.Parse(await resp.Content.ReadAsStringAsync()));
}
catch { return null; }
}
// -------------------------------------------------------------------
// Schreiben
// -------------------------------------------------------------------
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, RemoteAddressBook book)
{
var body = NachFonaria(contact, null).ToString();
OnDebug?.Invoke($"POST /addressbooks/{book.TagId}/contacts");
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/addressbooks/{book.TagId}/contacts", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Anlegen fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
}
return AusFonaria(JObject.Parse(respBody));
}
public async Task<UnifiedContact> UpdateContactAsync(
string contactId, UnifiedContact contact, RemoteAddressBook book)
{
// Den bekannten Stand mitgeben, damit nichts verlorengeht, was
// innerhalb einer verwalteten Eigenschaft liegt, aber im
// Sync-Modell nicht vorkommt.
JToken roh;
if (!_rohdaten.TryGetValue(contactId, out roh))
{
var vorher = await GetContactAsync(contactId, book);
_rohdaten.TryGetValue(contactId, out roh);
}
var body = NachFonaria(contact, roh).ToString();
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PutAsync(
$"{_baseUrl}/addressbooks/{book.TagId}/contacts/{contactId}", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Aendern von {contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
}
// Der Stand *nach* dem Schreiben ist massgeblich fuer die Baseline —
// sonst gilt der Kontakt beim naechsten Lauf erneut als geaendert.
return AusFonaria(JObject.Parse(respBody));
}
public async Task<bool> DeleteContactAsync(string contactId)
{
// Ohne Adressbuch im Pfad geht es bei Fonaria nicht. Die Engine
// ruft nur mit Kontakten des Profil-Buches, deshalb reicht das
// zuletzt benutzte.
if (_letztesBuch == null) return false;
var resp = await _http.DeleteAsync(
$"{_baseUrl}/addressbooks/{_letztesBuch}/contacts/{contactId}");
return resp.IsSuccessStatusCode;
}
private string _letztesBuch;
/// <summary>
/// Die Rohdaten der zuletzt gelesenen Kontakte, nach Kennung.
///
/// Sie werden beim Schreiben gebraucht: Zwei Dinge liegen innerhalb
/// einer Eigenschaft, die wir verwalten, gehoeren aber trotzdem nicht
/// uns — die Abteilung steckt im zweiten Teil von ORG, und neben der
/// geschaeftlichen Anschrift kann eine private stehen. Beides kennt das
/// Sync-Modell nicht. Wer es nicht zurueckschreibt, loescht es.
/// </summary>
private readonly Dictionary<string, JToken> _rohdaten = new Dictionary<string, JToken>();
// -------------------------------------------------------------------
// Umwandlung
// -------------------------------------------------------------------
private UnifiedContact AusFonaria(JToken item)
{
_letztesBuch = item["address_book_id"]?.ToString() ?? _letztesBuch;
var kennung = item["id"]?.ToString();
if (!string.IsNullOrEmpty(kennung)) _rohdaten[kennung] = item;
var kontakt = new UnifiedContact
{
StarfaceId = item["id"]?.ToString() ?? "",
FirstName = item["first_name"]?.ToString() ?? "",
LastName = item["last_name"]?.ToString() ?? "",
Company = item["organization"]?.ToString() ?? "",
JobTitle = item["title"]?.ToString() ?? "",
Salutation = item["prefix"]?.ToString() ?? "",
Notes = item["notes"]?.ToString() ?? "",
Birthday = item["birthday"]?.ToString() ?? ""
};
foreach (var mail in Eintraege(item["emails"]))
{
if (string.IsNullOrEmpty(kontakt.Email)) kontakt.Email = mail.Item1;
else if (string.IsNullOrEmpty(kontakt.EmailSecondary)) kontakt.EmailSecondary = mail.Item1;
}
// Die schlanke Liste fuehrt nur ein einzelnes Feld.
if (string.IsNullOrEmpty(kontakt.Email))
kontakt.Email = item["email"]?.ToString() ?? "";
foreach (var nummer in Nummern(item["numbers"]))
{
switch (nummer.Item2)
{
case "mobil": if (string.IsNullOrEmpty(kontakt.PhoneMobile)) kontakt.PhoneMobile = nummer.Item1; break;
case "home": if (string.IsNullOrEmpty(kontakt.PhoneHome)) kontakt.PhoneHome = nummer.Item1; break;
case "fax": if (string.IsNullOrEmpty(kontakt.Fax)) kontakt.Fax = nummer.Item1; break;
default: if (string.IsNullOrEmpty(kontakt.PhoneWork)) kontakt.PhoneWork = nummer.Item1; break;
}
}
var webadresse = Eintraege(item["urls"]).FirstOrDefault();
if (webadresse != null) kontakt.Website = webadresse.Item1;
// Von mehreren Anschriften traegt das Modell nur eine. Die
// geschaeftliche hat Vorrang — das ist die, um die es im
// Telefonbuch einer Anlage geht.
var anschriften = item["addresses"] as JArray;
if (anschriften != null && anschriften.Count > 0)
{
var gewaehlt = anschriften.FirstOrDefault(a => a["art"]?.ToString() == "work")
?? anschriften[0];
kontakt.Street = gewaehlt["strasse"]?.ToString() ?? "";
kontakt.City = gewaehlt["ort"]?.ToString() ?? "";
kontakt.PostalCode = gewaehlt["plz"]?.ToString() ?? "";
kontakt.State = gewaehlt["region"]?.ToString() ?? "";
kontakt.Country = gewaehlt["land"]?.ToString() ?? "";
}
return kontakt;
}
private static List<Tuple<string, string>> Eintraege(JToken liste)
{
var raus = new List<Tuple<string, string>>();
if (!(liste is JArray felder)) return raus;
foreach (var f in felder)
{
var wert = f["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(wert))
raus.Add(Tuple.Create(wert, f["kind"]?.ToString() ?? ""));
}
return raus;
}
private static List<Tuple<string, string>> Nummern(JToken liste)
{
var raus = new List<Tuple<string, string>>();
if (!(liste is JArray felder)) return raus;
foreach (var f in felder)
{
// Die Kurzform der Liste nennt das Feld "number", die
// Vollansicht "value".
var wert = f["number"]?.ToString() ?? f["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(wert))
raus.Add(Tuple.Create(wert, f["kind"]?.ToString() ?? "work"));
}
return raus;
}
private JObject NachFonaria(UnifiedContact contact, JToken bekannt)
{
var nummern = new JArray();
void Nummer(string wert, string art)
{
if (!string.IsNullOrWhiteSpace(wert))
nummern.Add(new JObject { ["value"] = wert, ["kind"] = art });
}
Nummer(contact.PhoneWork, "work");
Nummer(contact.PhoneMobile, "mobil");
Nummer(contact.PhoneHome, "home");
Nummer(contact.Fax, "fax");
var mails = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Email))
mails.Add(new JObject { ["value"] = contact.Email, ["kind"] = "work" });
if (!string.IsNullOrWhiteSpace(contact.EmailSecondary))
mails.Add(new JObject { ["value"] = contact.EmailSecondary, ["kind"] = "home" });
var webadressen = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Website))
webadressen.Add(new JObject { ["value"] = contact.Website, ["kind"] = "work" });
// Das Sync-Modell traegt genau eine Anschrift, die geschaeftliche.
// Alle uebrigen der Anlage werden unveraendert wieder mitgeschickt.
var anschriften = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Street) ||
!string.IsNullOrWhiteSpace(contact.City) ||
!string.IsNullOrWhiteSpace(contact.PostalCode))
{
anschriften.Add(new JObject
{
["art"] = "work",
["strasse"] = contact.Street ?? "",
["ort"] = contact.City ?? "",
["plz"] = contact.PostalCode ?? "",
["region"] = contact.State ?? "",
["land"] = contact.Country ?? ""
});
}
if (bekannt?["addresses"] is JArray vorhandene)
{
foreach (var a in vorhandene)
if (a["art"]?.ToString() != "work")
anschriften.Add(a.DeepClone());
}
// Dasselbe fuer die Abteilung: Sie steckt im zweiten Teil von ORG,
// das Sync-Modell kennt nur die Firma.
var abteilung = bekannt?["department"]?.ToString() ?? "";
return new JObject
{
// Der Kern: Nur diese Eigenschaften fasst Fonaria an.
["verwaltet"] = new JArray(VerwalteteFelder),
["display_name"] = contact.DisplayName,
["prefix"] = contact.Salutation ?? "",
["first_name"] = contact.FirstName ?? "",
["last_name"] = contact.LastName ?? "",
["organization"] = contact.Company ?? "",
["department"] = abteilung,
["title"] = contact.JobTitle ?? "",
["numbers"] = nummern,
["emails"] = mails,
["urls"] = webadressen,
["addresses"] = anschriften,
["birthday"] = contact.Birthday ?? "",
["notes"] = string.IsNullOrEmpty(contact.Notes) ? null : contact.Notes
};
}
public void Dispose()
{
_http?.Dispose();
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Die Gegenseite eines Abgleichs — eine Telefonanlage mit Adressbuechern.
///
/// Die SyncEngine kennt nur dieses Interface. Was dahinter steckt, eine
/// Starface oder eine Fonaria, entscheidet allein das Profil. Ohne diese
/// Trennung muesste die Engine fuer jede weitere Anlage angefasst werden —
/// und in ihr steckt die ganze Erfahrung mit getrennten Baselines,
/// Wiederzuordnung und Konfliktaufloesung, die man nicht zweimal haben will.
/// </summary>
public interface IContactBackend : IDisposable
{
/// <summary>Ausfuehrliche Meldungen fuer das Protokoll.</summary>
event Action<string> OnDebug;
/// <summary>Welche Anlage ist das? Nur fuer Meldungen an den Anwender.</summary>
string SystemName { get; }
/// <summary>
/// Welche vCard-Eigenschaften diese Anlage ueberhaupt fuehrt.
///
/// Entscheidend beim Schreiben: Fonaria haelt den Kontakt im vollen
/// vCard-Umfang. Ein Abgleich mit einer Starface darf deshalb nicht
/// die ganze Karte ersetzen — die Starface kennt weder Kategorien noch
/// Jahrestag noch Bild und wuerde sie beim ersten Durchlauf
/// mitloeschen, nur weil sie sie nicht mitschickt. Diese Liste sagt der
/// Gegenseite, wofuer wir uns zustaendig fuehlen.
/// </summary>
IReadOnlyList<string> VerwalteteFelder { get; }
Task<bool> LoginAsync();
Task LogoutAsync();
Task<List<RemoteAddressBook>> GetAddressBooksAsync();
Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book);
Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book);
Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, RemoteAddressBook book);
Task<UnifiedContact> UpdateContactAsync(string contactId, UnifiedContact contact, RemoteAddressBook book);
Task<bool> DeleteContactAsync(string contactId);
}
/// <summary>Waehlt die Umsetzung, die zum Profil passt.</summary>
public static class ContactBackendFactory
{
public static IContactBackend Create(SystemConnection connection)
{
switch (connection.System)
{
case PhoneSystem.Fonaria:
return new FonariaApiClient(connection);
default:
return new StarfaceApiClient(connection);
}
}
}
}
@@ -12,14 +12,25 @@ using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
public class StarfaceApiClient : IDisposable
public class StarfaceApiClient : IContactBackend
{
public string SystemName => "Starface";
/// <summary>
/// Was eine Starface an Feldern kennt — mehr gibt ihr Datenmodell nicht
/// her. Beim Schreiben nach Fonaria zaehlt genau diese Liste: Alles
/// andere in der dortigen vCard bleibt unangetastet, statt hier
/// mangels Kenntnis geloescht zu werden.
/// </summary>
public IReadOnlyList<string> VerwalteteFelder => UnifiedContact.VcardFelder;
private readonly HttpClient _http;
private readonly StarfaceConnection _connection;
private readonly SystemConnection _connection;
private readonly string _baseUrl;
private string _token;
public StarfaceApiClient(StarfaceConnection connection)
public StarfaceApiClient(SystemConnection connection)
{
_connection = connection;
@@ -112,9 +123,9 @@ namespace StarfaceOutlookSync.Services
catch { return null; }
}
public async Task<List<StarfaceAddressBook>> GetAddressBooksAsync()
public async Task<List<RemoteAddressBook>> GetAddressBooksAsync()
{
var books = new List<StarfaceAddressBook>();
var books = new List<RemoteAddressBook>();
// Alle Tags laden - die Starface nutzt Tags als Adressbuch-Zuordnung
var allTags = new JArray();
@@ -134,7 +145,7 @@ namespace StarfaceOutlookSync.Services
// Zentrales Adressbuch (folder/all)
var allTag = allTags.FirstOrDefault(t => t["name"]?.ToString() == "folder/all"
|| t["alias"]?.ToString()?.Contains("folder.all") == true);
books.Add(new StarfaceAddressBook
books.Add(new RemoteAddressBook
{
Type = "central",
TagId = allTag?["id"]?.ToString() ?? "",
@@ -149,7 +160,7 @@ namespace StarfaceOutlookSync.Services
(t["name"]?.ToString() == "folder/private" || t["alias"]?.ToString()?.Contains("folder.private") == true)
&& t["owner"]?.ToString() == userId);
books.Add(new StarfaceAddressBook
books.Add(new RemoteAddressBook
{
Type = "user",
UserId = userId,
@@ -165,7 +176,7 @@ namespace StarfaceOutlookSync.Services
// folder/all und folder/private bereits oben erfasst
if (tagName == "folder/all" || tagName == "folder/private") continue;
books.Add(new StarfaceAddressBook
books.Add(new RemoteAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
@@ -178,7 +189,7 @@ namespace StarfaceOutlookSync.Services
public event Action<string> OnDebug;
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
public async Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book)
{
var contacts = new List<UnifiedContact>();
int page = 0;
@@ -314,7 +325,7 @@ namespace StarfaceOutlookSync.Services
$"Synchronisation abgebrochen, um Dubletten zu vermeiden.");
}
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, RemoteAddressBook book)
{
var sfContact = MapToStarface(contact);
@@ -354,7 +365,7 @@ namespace StarfaceOutlookSync.Services
/// Hash-Baseline benoetigt, damit der Kontakt beim naechsten Sync nicht
/// erneut faelschlich als geaendert gilt.
/// </summary>
public async Task<UnifiedContact> UpdateContactAsync(string contactId, UnifiedContact contact, StarfaceAddressBook book)
public async Task<UnifiedContact> UpdateContactAsync(string contactId, UnifiedContact contact, RemoteAddressBook book)
{
var sfContact = MapToStarface(contact);
sfContact["id"] = contactId;
@@ -394,11 +405,16 @@ namespace StarfaceOutlookSync.Services
}
catch { }
return await GetContactAsync(contactId) ?? contact;
return await GetContactAsync(contactId, book) ?? contact;
}
/// <summary>Laedt einen einzelnen Kontakt mit allen Feldern.</summary>
public async Task<UnifiedContact> GetContactAsync(string contactId)
/// <summary>Laedt einen einzelnen Kontakt mit allen Feldern.
///
/// Das Adressbuch spielt hier keine Rolle: Die Starface findet einen
/// Kontakt ueber alle Buecher hinweg. Genau darauf beruht die Regel,
/// dass ein nicht in der Liste gefundener Kontakt nur verschoben und
/// nicht geloescht ist.</summary>
public async Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book = null)
{
try
{
@@ -115,6 +115,48 @@ namespace StarfaceOutlookSync.Services
}
}
/// <summary>
/// Fuehrt eine beliebige exklusive Aktion (z.B. Dubletten zusammenfuehren)
/// unter demselben Schutz wie ein Sync aus: lokaler Guard + clientueber-
/// greifende Lock-Datei. Gibt false zurueck, wenn gerade ein Sync/eine
/// Aktion laeuft oder ein anderer Arbeitsplatz aktiv ist.
/// </summary>
public async Task<bool> RunExclusiveAsync(Func<Task> action, Action<string> status)
{
// Auf einen gerade laufenden Sync warten (bis 60s), statt sofort
// abzubrechen - der Auto-Sync feuert ja jede Minute.
var guardDeadline = DateTime.UtcNow.AddSeconds(60);
while (Interlocked.CompareExchange(ref _running, 1, 0) != 0)
{
if (DateTime.UtcNow >= guardDeadline)
{
status?.Invoke("Es laeuft bereits ein Sync - bitte spaeter erneut versuchen.");
return false;
}
status?.Invoke("Warte, bis der laufende Sync fertig ist...");
await Task.Delay(1000);
}
SyncLock crossLock = null;
var sharedDir = UserSettings.Load().SharedDirectory;
try
{
crossLock = await AcquireCrossClientLock(sharedDir, status);
if (crossLock == null)
{
status?.Invoke("Anderer Arbeitsplatz ist gerade aktiv - abgebrochen.");
return false;
}
await action();
return true;
}
finally
{
crossLock?.Dispose();
Interlocked.Exchange(ref _running, 0);
}
}
private async Task<SyncLock> AcquireCrossClientLock(string dir, Action<string> status)
{
if (string.IsNullOrWhiteSpace(dir))
+11 -74
View File
@@ -36,69 +36,6 @@ namespace StarfaceOutlookSync.Services
m.LastSyncHash = "";
}
/// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
/// auf der anderen auch gefuellt (und gleich) sein.
/// Ein leeres Feld auf einer Seite und ein gefuelltes auf der anderen
/// bedeutet: verschiedene Kontakte.
/// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
foreach (var c in candidates)
{
if (IsMatch(contact, c))
return c;
}
return null;
}
private static bool IsMatch(UnifiedContact a, UnifiedContact b)
{
bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
&& (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
// Starke Identifikatoren
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase)
&& (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase);
bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork)
&& NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork))
|| (!string.IsNullOrEmpty(a.PhoneMobile) && !string.IsNullOrEmpty(b.PhoneMobile)
&& NormalizePhone(a.PhoneMobile) == NormalizePhone(b.PhoneMobile))
|| (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax)
&& NormalizePhone(a.Fax) == NormalizePhone(b.Fax));
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Widerspruch: beide haben eine E-Mail, aber unterschiedlich -> verschiedene Personen.
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
// Gleiche E-Mail ist der staerkste Identifikator und reicht allein.
if (emailMatch) return true;
// Gleicher voller Name reicht, solange keine widerspruechliche E-Mail vorliegt.
// (Telefon-Umformatierung durch Starface darf einen Namens-Treffer NICHT verhindern.)
if (nameMatch && !emailContradiction) return true;
// Schwacher Pfad: Telefon/Fax nur zusammen mit gleicher Firma und ohne E-Mail-Widerspruch.
if (phoneMatch && companyMatch && !emailContradiction) return true;
return false;
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
@@ -110,7 +47,7 @@ namespace StarfaceOutlookSync.Services
try
{
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
using (var starface = ContactBackendFactory.Create(profile.Connection))
{
starface.OnDebug += (msg) => Log(msg);
var loginOk = await starface.LoginAsync();
@@ -127,7 +64,7 @@ namespace StarfaceOutlookSync.Services
Log($"{outlookContacts.Count} Outlook-Kontakte geladen");
Log("Lade Starface-Kontakte...");
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
var starfaceContacts = await starface.GetContactsAsync(profile.AddressBook);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Bestehende Mappings laden
@@ -172,7 +109,7 @@ namespace StarfaceOutlookSync.Services
{
// Outlook-Kontakt nicht gefunden.
// Erst pruefen ob er vielleicht nur eine neue EntryID hat
var reMatch = FindMatch(sc, outlookContacts.Where(c =>
var reMatch = ContactMatcher.FindMatch(sc, outlookContacts.Where(c =>
!processedOutlookIds.Contains(c.OutlookEntryId)).ToList());
if (reMatch != null)
{
@@ -236,7 +173,7 @@ namespace StarfaceOutlookSync.Services
// (sonst Dublette).
// (b) per ID 404 -> in Starface WIRKLICH geloescht.
bool stillExists = !string.IsNullOrEmpty(mapping.StarfaceId)
&& await starface.GetContactAsync(mapping.StarfaceId) != null;
&& await starface.GetContactAsync(mapping.StarfaceId, profile.AddressBook) != null;
if (stillExists)
{
@@ -324,7 +261,7 @@ namespace StarfaceOutlookSync.Services
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{
// 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.AddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
@@ -355,7 +292,7 @@ namespace StarfaceOutlookSync.Services
var (merged, conflicts) = ContactMerger.Merge(
mapping.LastOutlook, mapping.LastStarface, oc, sc, outlookWins: true);
var updatedSf = await starface.UpdateContactAsync(mapping.StarfaceId, merged, profile.StarfaceAddressBook);
var updatedSf = await starface.UpdateContactAsync(mapping.StarfaceId, merged, profile.AddressBook);
var updatedOl = _outlookService.UpdateContact(mapping.OutlookEntryId, merged);
if (updatedSf != null || updatedOl != null)
@@ -382,7 +319,7 @@ namespace StarfaceOutlookSync.Services
{
// 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.AddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
@@ -428,11 +365,11 @@ namespace StarfaceOutlookSync.Services
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
var match = ContactMatcher.FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook);
var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.AddressBook);
if (updated != null)
{
newMappings.Add(new SyncMapping
@@ -455,7 +392,7 @@ namespace StarfaceOutlookSync.Services
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
var created = await starface.CreateContactAsync(oc, profile.AddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
newMappings.Add(new SyncMapping
@@ -508,7 +445,7 @@ namespace StarfaceOutlookSync.Services
try
{
// Duplikat-Check: existiert der Kontakt schon in Outlook?
var match = FindMatch(sc, unmappedOutlook);
var match = ContactMatcher.FindMatch(sc, unmappedOutlook);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
@@ -7,9 +7,9 @@
<AssemblyTitle>Starface Outlook Sync</AssemblyTitle>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product>
<Version>0.0.0.30</Version>
<AssemblyVersion>0.0.0.30</AssemblyVersion>
<FileVersion>0.0.0.30</FileVersion>
<Version>0.0.3.3</Version>
<AssemblyVersion>0.0.3.3</AssemblyVersion>
<FileVersion>0.0.3.3</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
+1 -1
View File
@@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label
{
Text = "Version 0.0.0.30",
Text = "Version 0.0.3.3",
Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray
+223
View File
@@ -0,0 +1,223 @@
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
/// <summary>
/// Dialog zum Zusammenfuehren doppelter Kontakte fuer EIN Profil.
/// Analysiert zuerst (Vorschau), fuehrt nach Bestaetigung unter derselben
/// Sperre wie ein Sync zusammen.
/// </summary>
public class DedupeForm : Form
{
private readonly SyncProfile _profile;
private readonly DedupeService _dedupe = new DedupeService();
private readonly SyncCoordinator _coordinator = new SyncCoordinator();
private Label _lblInfo, _lblSummary;
private CheckBox _chkDetail;
private TextBox _txtLog;
private Button _btnStart, _btnClose;
private DedupeService.DedupePlan _plan;
private bool _busy;
public DedupeForm(SyncProfile profile)
{
_profile = profile;
_dedupe.OnProgress += AppendLog;
InitializeComponent();
Load += async (s, e) => await Analyze();
}
private void InitializeComponent()
{
Text = $"Dubletten zusammenfuehren - {_profile.Name}";
Size = new Size(640, 500);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
MinimizeBox = false;
_lblInfo = new Label
{
Text = $"Profil: {_profile.Name} ({_profile.Connection.System} <-> {_profile.OutlookFolderName})",
Left = 12, Top = 12, Width = 600, Height = 20
};
_lblSummary = new Label
{
Text = "Analysiere...",
Left = 12, Top = 36, Width = 600, Height = 40, ForeColor = Color.DimGray
};
_chkDetail = new CheckBox
{
Text = "Detailvorschau anzeigen (welche Kontakte zusammengefuehrt werden)",
Left = 12, Top = 80, AutoSize = true, Enabled = false
};
_chkDetail.CheckedChanged += (s, e) => { if (_chkDetail.Checked) RenderDetail(); };
_txtLog = new TextBox
{
Left = 12, Top = 108, Width = 604, Height = 300,
Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false,
BackColor = Color.FromArgb(30, 30, 30), ForeColor = Color.FromArgb(212, 212, 212),
Font = new Font("Consolas", 9),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom
};
_btnStart = new Button
{
Text = "Zusammenfuehren starten", Left = 12, Top = 420, Width = 190, Height = 30,
Enabled = false, Anchor = AnchorStyles.Bottom | AnchorStyles.Left
};
_btnStart.Click += async (s, e) => await Execute();
_btnClose = new Button
{
Text = "Schliessen", Left = 526, Top = 420, Width = 90, Height = 30,
DialogResult = DialogResult.Cancel, Anchor = AnchorStyles.Bottom | AnchorStyles.Right
};
Controls.AddRange(new Control[] { _lblInfo, _lblSummary, _chkDetail, _txtLog, _btnStart, _btnClose });
CancelButton = _btnClose;
}
private void AppendLog(string message)
{
// Der Aufruf kann aus dem Hintergrund kommen, nachdem das Fenster
// bereits geschlossen/entsorgt wurde -> defensiv absichern.
if (IsDisposed || Disposing) return;
try
{
if (InvokeRequired) { BeginInvoke(new Action(() => AppendLog(message))); return; }
if (_txtLog == null || _txtLog.IsDisposed) return;
_txtLog.AppendText(message + "\r\n");
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { } // Handle noch nicht/nicht mehr da
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
// Nicht schliessen, solange Analyse/Zusammenfuehrung laeuft - sonst
// greifen die Hintergrund-Fortsetzungen auf entsorgte Controls zu.
if (_busy)
{
e.Cancel = true;
_lblSummary.Text = "Bitte warten - der Vorgang laeuft noch...";
_lblSummary.ForeColor = Color.OrangeRed;
return;
}
base.OnFormClosing(e);
}
private async Task Analyze()
{
_busy = true;
_btnStart.Enabled = false;
try
{
_plan = await Task.Run(() => _dedupe.AnalyzeAsync(_profile));
var s = $"Telefonanlage: {_plan.RemoteGroups.Count} Gruppe(n) mit Dubletten, {_plan.RemoteDeletions} Kontakt(e) werden zusammengefuehrt.\r\n" +
$"Outlook: {_plan.OutlookGroups.Count} Gruppe(n) mit Dubletten, {_plan.OutlookDeletions} Kontakt(e) werden zusammengefuehrt.";
_lblSummary.Text = _plan.HasWork ? s : "Keine Dubletten gefunden. Alles sauber. ✓";
_lblSummary.ForeColor = _plan.HasWork ? Color.OrangeRed : Color.Green;
_chkDetail.Enabled = _plan.HasWork;
_btnStart.Enabled = _plan.HasWork;
if (_chkDetail.Checked) RenderDetail();
}
catch (Exception ex)
{
_lblSummary.Text = "Analyse fehlgeschlagen: " + ex.Message;
_lblSummary.ForeColor = Color.Red;
AppendLog("FEHLER: " + ex.Message);
}
finally { _busy = false; }
}
private void RenderDetail()
{
if (_plan == null) return;
AppendLog("");
AppendLog("=== Vorschau: Telefonanlage ===");
foreach (var g in _plan.RemoteGroups)
AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)");
AppendLog("=== Vorschau: Outlook ===");
foreach (var g in _plan.OutlookGroups)
AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)");
AppendLog("");
}
private async Task Execute()
{
if (_busy || _plan == null || !_plan.HasWork) return;
var confirm = MessageBox.Show(this,
$"Jetzt zusammenfuehren?\n\n" +
$"Telefonanlage: {_plan.RemoteDeletions} Dublette(n) werden geloescht.\n" +
$"Outlook: {_plan.OutlookDeletions} Dublette(n) werden geloescht.\n\n" +
$"Die Daten der Dubletten werden vorher in den behaltenen Kontakt uebernommen (leere Felder aufgefuellt).\n" +
$"Danach bitte einmal synchronisieren.",
"Dubletten zusammenfuehren", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (confirm != DialogResult.Yes) return;
_busy = true;
_btnStart.Enabled = false;
_btnClose.Enabled = false;
AppendLog("Starte Zusammenfuehrung...");
Logger.Log($"Dubletten-Zusammenfuehrung gestartet: Profil '{_profile.Name}'");
try
{
DedupeService.DedupeReport report = null;
bool ran = await _coordinator.RunExclusiveAsync(
async () => { report = await Task.Run(() => _dedupe.ExecuteAsync(_profile)); },
status: AppendLog);
if (!ran)
{
AppendLog("Abgebrochen: gerade laeuft ein Sync (oder ein anderer Arbeitsplatz ist aktiv).");
_lblSummary.Text = "Nicht ausgefuehrt - es laeuft gerade ein Sync. Bitte kurz warten und erneut versuchen.";
_lblSummary.ForeColor = Color.OrangeRed;
MessageBox.Show(this,
"Die Zusammenfuehrung wurde NICHT ausgefuehrt, weil gerade ein Sync laeuft " +
"(oder ein anderer Arbeitsplatz aktiv ist).\n\nBitte kurz warten und erneut auf " +
"'Zusammenfuehren starten' klicken.",
"Dubletten", MessageBoxButtons.OK, MessageBoxIcon.Warning);
_btnStart.Enabled = true; // erneuter Versuch moeglich
}
else if (report != null)
{
var summary = $"Fertig. Anlage: {report.RemoteMerged} Gruppe(n), {report.RemoteDeleted} geloescht. " +
$"Outlook: {report.OutlookMerged} Gruppe(n), {report.OutlookDeleted} geloescht. " +
$"Fehler: {report.Errors}.";
AppendLog(summary);
foreach (var m in report.Messages) AppendLog(" " + m);
AppendLog("Bitte jetzt einmal synchronisieren, damit die Zuordnungen sauber neu aufgebaut werden.");
Logger.Log("Dubletten-Zusammenfuehrung: " + summary);
foreach (var m in report.Messages) Logger.Log(" " + m);
_lblSummary.Text = summary + " -> Jetzt synchronisieren.";
_lblSummary.ForeColor = report.Errors > 0 ? Color.OrangeRed : Color.Green;
}
}
catch (Exception ex)
{
AppendLog("FEHLER: " + ex.Message);
Logger.Log($"Dubletten-Zusammenfuehrung FEHLER '{_profile.Name}': {ex.Message}");
}
finally
{
_busy = false;
_btnClose.Enabled = true;
}
}
}
}
+36 -7
View File
@@ -19,7 +19,7 @@ namespace StarfaceOutlookSync.UI
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnLog, _btnInfo;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
@@ -83,8 +83,8 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(830, 450);
MinimumSize = new Size(830, 350);
Size = new Size(930, 450);
MinimumSize = new Size(930, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
@@ -126,6 +126,9 @@ namespace StarfaceOutlookSync.UI
_btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnDedupe = new Button { Text = "Dubletten", Width = 85, Height = 30 };
_btnDedupe.Click += (s, e) => ShowDedupe();
_btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 };
_btnReset.Click += (s, e) => ResetSync();
@@ -138,7 +141,7 @@ namespace StarfaceOutlookSync.UI
_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, _btnLog, _btnInfo });
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
@@ -241,7 +244,7 @@ namespace StarfaceOutlookSync.UI
var item = new ListViewItem(new[]
{
p.Name,
$"{p.StarfaceConnection.Host} / {p.StarfaceAddressBook.Name}",
$"{p.Connection.Host} / {p.AddressBook.Name}",
p.OutlookFolderName,
dirText,
lastSync
@@ -355,9 +358,11 @@ namespace StarfaceOutlookSync.UI
{
// Lokaler Guard, Lock-Datei, Konflikt-Notizen und Protokoll laufen
// zentral im Coordinator (gemeinsam mit dem manuellen Sync-Pfad).
// WICHTIG: die Engine im Hintergrund laufen lassen, damit das
// Fenster und das Tray-Kontextmenue waehrend des Syncs reagieren.
var outcome = await _coordinator.RunAsync(
profile,
runEngine: () => _syncEngine.SyncProfileAsync(profile),
runEngine: () => Task.Run(() => _syncEngine.SyncProfileAsync(profile)),
status: SetStatus);
if (outcome.Skipped)
@@ -423,7 +428,13 @@ namespace StarfaceOutlookSync.UI
private void Balloon(int ms, string title, string text, ToolTipIcon icon, bool warn)
{
bool allow = warn ? _notifyWarn : _notifyGeneral;
if (allow) _trayIcon.ShowBalloonTip(ms, title, text, icon);
if (!allow) return;
if (InvokeRequired)
{
BeginInvoke(new Action(() => _trayIcon.ShowBalloonTip(ms, title, text, icon)));
return;
}
_trayIcon.ShowBalloonTip(ms, title, text, icon);
}
private void SetStatus(string text)
@@ -458,6 +469,24 @@ namespace StarfaceOutlookSync.UI
}
}
private void ShowDedupe()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte zuerst ein Profil auswaehlen.", "Dubletten",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
using (var form = new DedupeForm(profile))
{
form.ShowDialog(this);
}
RefreshProfileList();
}
private void ExitApplication()
{
_autoSyncTimer?.Stop();
+69 -26
View File
@@ -24,10 +24,30 @@ namespace StarfaceOutlookSync.UI
private Label _lblTestResult;
private Label _lblDirectionHint;
private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>();
private List<RemoteAddressBook> _addressBooks = new List<RemoteAddressBook>();
private List<string> _outlookFolderPaths = new List<string>();
private string _selectedOutlookPath = "";
private string _selectedOutlookName = "";
private ComboBox _cmbSystem;
private Label _lblAnlage;
private Label _lblLoginId;
private Label _lblAddressBook;
private string AnlagenName => GewaehlteAnlage == PhoneSystem.Fonaria ? "Fonaria" : "Starface";
private PhoneSystem GewaehlteAnlage =>
_cmbSystem != null && _cmbSystem.SelectedIndex == 1 ? PhoneSystem.Fonaria : PhoneSystem.Starface;
/// <summary>Beschriftungen, die je nach Anlage anders heissen.</summary>
private void UpdateSystemLabels()
{
var fonaria = GewaehlteAnlage == PhoneSystem.Fonaria;
if (_lblLoginId != null)
_lblLoginId.Text = fonaria ? "Benutzername:" : "Login-ID:";
if (_lblAddressBook != null)
_lblAddressBook.Text = fonaria ? "Fonaria-Adressbuch:" : "Starface-Adressbuch:";
UpdateDirectionHint();
}
public ProfileEditorForm(SyncProfile profile)
{
@@ -54,8 +74,26 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Profilname:", 12, y)); y += 22;
_txtName = new TextBox { Left = 12, Top = y, Width = 420 }; panel.Controls.Add(_txtName); y += 32;
// === Starface ===
panel.Controls.Add(MakeSectionLabel("Starface-Verbindung", 12, y)); y += 26;
// === Anlage ===
_lblAnlage = MakeSectionLabel("Verbindung zur Telefonanlage", 12, y); y += 26;
panel.Controls.Add(_lblAnlage);
panel.Controls.Add(MakeLabel("Anlage:", 12, y)); y += 22;
_cmbSystem = new ComboBox { Left = 12, Top = y, Width = 200, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbSystem.Items.AddRange(new object[] { "Starface", "Fonaria" });
_cmbSystem.SelectedIndex = 0;
// Beide sprechen ihre eigene Schnittstelle; der Vorgabeport ist
// derselbe, aber die Adressbuecher heissen anders. Beim Wechsel
// muss die Auswahl darum neu geladen werden.
_cmbSystem.SelectedIndexChanged += (s, e) =>
{
_addressBooks.Clear();
_cmbAddressBook.Items.Clear();
_lblTestResult.Text = "Anlage gewechselt - bitte Adressbuecher neu laden.";
_lblTestResult.ForeColor = Color.FromArgb(150, 80, 0);
UpdateSystemLabels();
};
panel.Controls.Add(_cmbSystem); y += 32;
panel.Controls.Add(MakeLabel("Host / IP-Adresse:", 12, y)); y += 22;
_txtHost = new TextBox { Left = 12, Top = y, Width = 320 };
@@ -67,7 +105,8 @@ namespace StarfaceOutlookSync.UI
_chkSsl = new CheckBox { Text = "HTTPS verwenden", Left = 12, Top = y, Checked = true, AutoSize = true };
panel.Controls.Add(_chkSsl); y += 28;
panel.Controls.Add(MakeLabel("Login-ID:", 12, y)); y += 22;
_lblLoginId = MakeLabel("Login-ID:", 12, y);
panel.Controls.Add(_lblLoginId); y += 22;
_txtLoginId = new TextBox { Left = 12, Top = y, Width = 200 }; panel.Controls.Add(_txtLoginId); y += 32;
panel.Controls.Add(MakeLabel("Kennwort:", 12, y)); y += 22;
@@ -84,7 +123,8 @@ namespace StarfaceOutlookSync.UI
_lblTestResult = new Label { Left = 12, Top = y, Width = 420, Height = 20, ForeColor = Color.Gray };
panel.Controls.Add(_lblTestResult); y += 26;
panel.Controls.Add(MakeLabel("Starface-Adressbuch:", 12, y)); y += 22;
_lblAddressBook = MakeLabel("Adressbuch der Anlage:", 12, y);
panel.Controls.Add(_lblAddressBook); y += 22;
_cmbAddressBook = new ComboBox { Left = 12, Top = y, Width = 420, DropDownStyle = ComboBoxStyle.DropDownList };
panel.Controls.Add(_cmbAddressBook); y += 32;
@@ -100,7 +140,7 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Sync-Richtung:", 12, y)); y += 22;
_cmbDirection = new ComboBox { Left = 12, Top = y, Width = 250, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" });
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Anlage", "Anlage -> Outlook" });
_cmbDirection.SelectedIndex = 0;
panel.Controls.Add(_cmbDirection); y += 28;
@@ -138,11 +178,11 @@ namespace StarfaceOutlookSync.UI
{
switch (_cmbDirection.SelectedIndex)
{
case 1: // Outlook -> Starface
_lblDirectionHint.Text = "Achtung: Das Starface-Adressbuch wird zur exakten Kopie von Outlook.\nKontakte, die nur in Starface existieren, werden geloescht.";
case 1: // Outlook -> Anlage
_lblDirectionHint.Text = $"Achtung: Das Adressbuch der {AnlagenName} wird zur exakten Kopie von Outlook.\nKontakte, die nur dort existieren, werden geloescht.";
break;
case 2: // Starface -> Outlook
_lblDirectionHint.Text = "Achtung: Der Outlook-Ordner wird zur exakten Kopie von Starface.\nKontakte, die nur in Outlook existieren, werden geloescht.";
case 2: // Anlage -> Outlook
_lblDirectionHint.Text = $"Achtung: Der Outlook-Ordner wird zur exakten Kopie der {AnlagenName}.\nKontakte, die nur in Outlook existieren, werden geloescht.";
break;
default: // Bidirektional
_lblDirectionHint.Text = "Aenderungen werden in beide Richtungen abgeglichen.";
@@ -182,15 +222,17 @@ namespace StarfaceOutlookSync.UI
if (_existingProfile != null)
{
_txtName.Text = _existingProfile.Name;
_txtHost.Text = _existingProfile.StarfaceConnection.Host;
_txtPort.Text = _existingProfile.StarfaceConnection.Port.ToString();
_chkSsl.Checked = _existingProfile.StarfaceConnection.UseSsl;
_txtLoginId.Text = _existingProfile.StarfaceConnection.LoginId;
_txtPassword.Text = _existingProfile.StarfaceConnection.Password;
_cmbSystem.SelectedIndex = _existingProfile.Connection.System == PhoneSystem.Fonaria ? 1 : 0;
_txtHost.Text = _existingProfile.Connection.Host;
_txtPort.Text = _existingProfile.Connection.Port.ToString();
_chkSsl.Checked = _existingProfile.Connection.UseSsl;
_txtLoginId.Text = _existingProfile.Connection.LoginId;
_txtPassword.Text = _existingProfile.Connection.Password;
_chkEnabled.Checked = _existingProfile.Enabled;
_numAutoSync.Value = _existingProfile.AutoSyncIntervalMinutes;
_cmbDirection.SelectedIndex = (int)_existingProfile.SyncDirection;
UpdateSystemLabels();
// Outlook-Ordner
_selectedOutlookPath = _existingProfile.OutlookFolderPath;
@@ -198,10 +240,10 @@ namespace StarfaceOutlookSync.UI
_txtOutlookFolder.Text = _selectedOutlookPath;
// Adressbuch
if (_existingProfile.StarfaceAddressBook != null)
if (_existingProfile.AddressBook != null)
{
_addressBooks.Add(_existingProfile.StarfaceAddressBook);
_cmbAddressBook.Items.Add(_existingProfile.StarfaceAddressBook.Name);
_addressBooks.Add(_existingProfile.AddressBook);
_cmbAddressBook.Items.Add(_existingProfile.AddressBook.Name);
_cmbAddressBook.SelectedIndex = 0;
}
}
@@ -227,10 +269,11 @@ namespace StarfaceOutlookSync.UI
}
}
private StarfaceConnection GetConnection()
private SystemConnection GetConnection()
{
return new StarfaceConnection
return new SystemConnection
{
System = GewaehlteAnlage,
Host = _txtHost.Text.Trim(),
Port = int.TryParse(_txtPort.Text, out var p) ? p : 443,
UseSsl = _chkSsl.Checked,
@@ -247,7 +290,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (ok)
@@ -280,7 +323,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (!ok)
@@ -350,8 +393,8 @@ namespace StarfaceOutlookSync.UI
{
Id = _existingProfile?.Id ?? _pm.GenerateId(),
Name = _txtName.Text.Trim(),
StarfaceConnection = GetConnection(),
StarfaceAddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
Connection = GetConnection(),
AddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
OutlookFolderPath = _selectedOutlookPath,
OutlookFolderName = _selectedOutlookName,
SyncDirection = (SyncDirection)_cmbDirection.SelectedIndex,
@@ -367,8 +410,8 @@ namespace StarfaceOutlookSync.UI
else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.StarfaceAddressBook?.TagId != profile.StarfaceAddressBook?.TagId
|| _existingProfile.StarfaceAddressBook?.Type != profile.StarfaceAddressBook?.Type)
if (_existingProfile.AddressBook?.TagId != profile.AddressBook?.TagId
|| _existingProfile.AddressBook?.Type != profile.AddressBook?.Type)
{
_pm.SaveMappings(profile.Id, new List<SyncMapping>());
profile.LastSync = "";
@@ -35,7 +35,7 @@ namespace StarfaceOutlookSync.UI
var infoLabel = new Label
{
Text = $"{_profile.StarfaceConnection.Host} ({_profile.StarfaceAddressBook.Name}) <-> {_profile.OutlookFolderName}",
Text = $"{_profile.Connection.Host} ({_profile.AddressBook.Name}) <-> {_profile.OutlookFolderName}",
Left = 12, Top = 12, Width = 460, AutoSize = false, Height = 20
};