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 };