diff --git a/src/StarfaceOutlookSync/Models/SyncProfile.cs b/src/StarfaceOutlookSync/Models/SyncProfile.cs
index 416f9f5e..516da32d 100644
--- a/src/StarfaceOutlookSync/Models/SyncProfile.cs
+++ b/src/StarfaceOutlookSync/Models/SyncProfile.cs
@@ -1,5 +1,14 @@
+using Newtonsoft.Json;
+
namespace StarfaceOutlookSync.Models
{
+ /// Welche Telefonanlage auf der anderen Seite steht.
+ 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
+ ///
+ /// Ein Adressbuch auf der Gegenseite.
+ ///
+ /// Die Felder heissen noch nach der Starface, weil dort ihre Bedeutung
+ /// herkommt: Type unterscheidet zentrales, persoenliches und
+ /// Tag-Adressbuch, TagId traegt die Kennung. Bei Fonaria steht in
+ /// TagId schlicht die Nummer des Adressbuchs und in Type
+ /// dessen Art (private, shared, system).
+ ///
+ 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
diff --git a/src/StarfaceOutlookSync/Models/UnifiedContact.cs b/src/StarfaceOutlookSync/Models/UnifiedContact.cs
index a72299a4..98bf1bce 100644
--- a/src/StarfaceOutlookSync/Models/UnifiedContact.cs
+++ b/src/StarfaceOutlookSync/Models/UnifiedContact.cs
@@ -2,6 +2,22 @@ namespace StarfaceOutlookSync.Models
{
public class UnifiedContact
{
+ ///
+ /// 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.
+ ///
+ 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; } = "";
diff --git a/src/StarfaceOutlookSync/Services/FonariaApiClient.cs b/src/StarfaceOutlookSync/Services/FonariaApiClient.cs
new file mode 100644
index 00000000..3e5a000a
--- /dev/null
+++ b/src/StarfaceOutlookSync/Services/FonariaApiClient.cs
@@ -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
+{
+ ///
+ /// 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 verwaltet 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.
+ ///
+ public class FonariaApiClient : IContactBackend
+ {
+ private readonly HttpClient _http;
+ private readonly SystemConnection _connection;
+ private readonly string _baseUrl;
+ private string _token;
+
+ public event Action OnDebug;
+
+ public string SystemName => "Fonaria";
+
+ public IReadOnlyList 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 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> GetAddressBooksAsync()
+ {
+ var buecher = new List();
+ 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> 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();
+ 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 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 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 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 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;
+
+ ///
+ /// 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.
+ ///
+ private readonly Dictionary _rohdaten = new Dictionary();
+
+ // -------------------------------------------------------------------
+ // 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> Eintraege(JToken liste)
+ {
+ var raus = new List>();
+ 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> Nummern(JToken liste)
+ {
+ var raus = new List>();
+ 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();
+ }
+ }
+}
diff --git a/src/StarfaceOutlookSync/Services/IContactBackend.cs b/src/StarfaceOutlookSync/Services/IContactBackend.cs
new file mode 100644
index 00000000..271fbdc8
--- /dev/null
+++ b/src/StarfaceOutlookSync/Services/IContactBackend.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using StarfaceOutlookSync.Models;
+
+namespace StarfaceOutlookSync.Services
+{
+ ///
+ /// 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.
+ ///
+ public interface IContactBackend : IDisposable
+ {
+ /// Ausfuehrliche Meldungen fuer das Protokoll.
+ event Action OnDebug;
+
+ /// Welche Anlage ist das? Nur fuer Meldungen an den Anwender.
+ string SystemName { get; }
+
+ ///
+ /// 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.
+ ///
+ IReadOnlyList VerwalteteFelder { get; }
+
+ Task LoginAsync();
+ Task LogoutAsync();
+
+ Task> GetAddressBooksAsync();
+ Task> GetContactsAsync(RemoteAddressBook book);
+ Task GetContactAsync(string contactId, RemoteAddressBook book);
+
+ Task CreateContactAsync(UnifiedContact contact, RemoteAddressBook book);
+ Task UpdateContactAsync(string contactId, UnifiedContact contact, RemoteAddressBook book);
+ Task DeleteContactAsync(string contactId);
+ }
+
+ /// Waehlt die Umsetzung, die zum Profil passt.
+ 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);
+ }
+ }
+ }
+}
diff --git a/src/StarfaceOutlookSync/Services/StarfaceApiClient.cs b/src/StarfaceOutlookSync/Services/StarfaceApiClient.cs
index 19808248..762c55c5 100644
--- a/src/StarfaceOutlookSync/Services/StarfaceApiClient.cs
+++ b/src/StarfaceOutlookSync/Services/StarfaceApiClient.cs
@@ -12,14 +12,25 @@ using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
- public class StarfaceApiClient : IDisposable
+ public class StarfaceApiClient : IContactBackend
{
+ public string SystemName => "Starface";
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList 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> GetAddressBooksAsync()
+ public async Task> GetAddressBooksAsync()
{
- var books = new List();
+ var books = new List();
// 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 OnDebug;
- public async Task> GetContactsAsync(StarfaceAddressBook book)
+ public async Task> GetContactsAsync(RemoteAddressBook book)
{
var contacts = new List();
int page = 0;
@@ -314,7 +325,7 @@ namespace StarfaceOutlookSync.Services
$"Synchronisation abgebrochen, um Dubletten zu vermeiden.");
}
- public async Task CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
+ public async Task 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.
///
- public async Task UpdateContactAsync(string contactId, UnifiedContact contact, StarfaceAddressBook book)
+ public async Task 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;
}
- /// Laedt einen einzelnen Kontakt mit allen Feldern.
- public async Task GetContactAsync(string contactId)
+ /// 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.
+ public async Task GetContactAsync(string contactId, RemoteAddressBook book = null)
{
try
{
diff --git a/src/StarfaceOutlookSync/Services/SyncEngine.cs b/src/StarfaceOutlookSync/Services/SyncEngine.cs
index f67e91b4..5c806215 100644
--- a/src/StarfaceOutlookSync/Services/SyncEngine.cs
+++ b/src/StarfaceOutlookSync/Services/SyncEngine.cs
@@ -110,7 +110,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 +127,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
@@ -236,7 +236,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 +324,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 +355,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 +382,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);
@@ -432,7 +432,7 @@ namespace StarfaceOutlookSync.Services
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 +455,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
diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs
index 570ad1bf..df34fd38 100644
--- a/src/StarfaceOutlookSync/UI/MainForm.cs
+++ b/src/StarfaceOutlookSync/UI/MainForm.cs
@@ -241,7 +241,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
diff --git a/src/StarfaceOutlookSync/UI/ProfileEditorForm.cs b/src/StarfaceOutlookSync/UI/ProfileEditorForm.cs
index 0a4832ac..5e86a0f4 100644
--- a/src/StarfaceOutlookSync/UI/ProfileEditorForm.cs
+++ b/src/StarfaceOutlookSync/UI/ProfileEditorForm.cs
@@ -24,10 +24,30 @@ namespace StarfaceOutlookSync.UI
private Label _lblTestResult;
private Label _lblDirectionHint;
- private List _addressBooks = new List();
+ private List _addressBooks = new List();
private List _outlookFolderPaths = new List();
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;
+
+ /// Beschriftungen, die je nach Anlage anders heissen.
+ 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());
profile.LastSync = "";
diff --git a/src/StarfaceOutlookSync/UI/SyncProgressForm.cs b/src/StarfaceOutlookSync/UI/SyncProgressForm.cs
index a6aa5344..19e9031d 100644
--- a/src/StarfaceOutlookSync/UI/SyncProgressForm.cs
+++ b/src/StarfaceOutlookSync/UI/SyncProgressForm.cs
@@ -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
};