Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0b96f16a | ||
|
|
e7a87fbdf1 | ||
|
|
0076d84062 | ||
|
|
6cc3103040 | ||
|
|
844094d5c5 | ||
|
|
da20d341d7 | ||
|
|
ca05af75e3 | ||
|
|
9767299edd | ||
|
|
6ecd5d7fa3 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user