Fonaria als zweite Anlage neben Starface

Die SyncEngine kennt jetzt nur noch IContactBackend. Was dahinter steckt,
entscheidet das Profil — der Ablauf mit getrennten Baselines, Wiederzuordnung
und Konfliktaufloesung bleibt unangetastet. In ihm steckt die Erfahrung, die
man nicht zweimal machen will.

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

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

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

Beides wird jetzt aus dem gelesenen Stand unveraendert zurueckgeschrieben.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:10:43 +02:00
co-authored by Claude Opus 5
parent 88635c04f8
commit 6ecd5d7fa3
9 changed files with 655 additions and 54 deletions
+35 -4
View File
@@ -1,5 +1,14 @@
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
{
/// <summary>Welche Telefonanlage auf der anderen Seite steht.</summary>
public enum PhoneSystem
{
Starface,
Fonaria
}
public enum SyncDirection
{
Both,
@@ -7,8 +16,12 @@ namespace StarfaceOutlookSync.Models
StarfaceToOutlook
}
public class StarfaceConnection
public class SystemConnection
{
// Voreinstellung Starface, damit Profile aus der Zeit vor Fonaria
// unveraendert weiterlaufen: Was im JSON fehlt, ist eine Starface.
public PhoneSystem System { get; set; } = PhoneSystem.Starface;
public string Host { get; set; } = "";
public int Port { get; set; } = 443;
public bool UseSsl { get; set; } = true;
@@ -16,7 +29,16 @@ namespace StarfaceOutlookSync.Models
public string Password { get; set; } = "";
}
public class StarfaceAddressBook
/// <summary>
/// Ein Adressbuch auf der Gegenseite.
///
/// Die Felder heissen noch nach der Starface, weil dort ihre Bedeutung
/// herkommt: <c>Type</c> unterscheidet zentrales, persoenliches und
/// Tag-Adressbuch, <c>TagId</c> traegt die Kennung. Bei Fonaria steht in
/// <c>TagId</c> schlicht die Nummer des Adressbuchs und in <c>Type</c>
/// dessen Art (private, shared, system).
/// </summary>
public class RemoteAddressBook
{
public string Type { get; set; } = "central"; // central, user, tag
public string UserId { get; set; } = "";
@@ -30,8 +52,13 @@ namespace StarfaceOutlookSync.Models
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StarfaceConnection StarfaceConnection { get; set; } = new StarfaceConnection();
public StarfaceAddressBook StarfaceAddressBook { get; set; } = new StarfaceAddressBook();
// Die JSON-Namen bleiben, wie sie waren — sonst verloeren alle
// bestehenden Profile beim ersten Start ihre Verbindungsdaten.
[JsonProperty("StarfaceConnection")]
public SystemConnection Connection { get; set; } = new SystemConnection();
[JsonProperty("StarfaceAddressBook")]
public RemoteAddressBook AddressBook { get; set; } = new RemoteAddressBook();
public string OutlookFolderPath { get; set; } = "";
public string OutlookFolderName { get; set; } = "Kontakte";
public SyncDirection SyncDirection { get; set; } = SyncDirection.Both;
@@ -44,6 +71,10 @@ namespace StarfaceOutlookSync.Models
{
public string ProfileId { get; set; } = "";
public string OutlookEntryId { get; set; } = "";
// Die Kennung auf der Gegenseite — bei Fonaria die Kontakt-ID. Der
// Name bleibt: Er steht in jeder bereits gespeicherten Zuordnung, und
// ein Umbenennen wuerde sie alle entwerten.
public string StarfaceId { get; set; } = "";
// Getrennte Baselines pro Seite. Outlook und Starface stellen denselben
@@ -2,6 +2,22 @@ namespace StarfaceOutlookSync.Models
{
public class UnifiedContact
{
/// <summary>
/// Welche vCard-Eigenschaften dieses Modell abbildet.
///
/// Fonaria fuehrt den Kontakt im vollen vCard-Umfang — Kategorien,
/// Jahrestag, Spitzname, Bild, zweite Anschrift. Hier steht davon nur
/// ein Ausschnitt, weil die Starface nicht mehr kann. Beim Schreiben
/// nach Fonaria wird diese Liste mitgeschickt: Sie sagt der Anlage,
/// wofuer wir zustaendig sind. Alles andere bleibt dort unangetastet,
/// statt geloescht zu werden, nur weil wir es nicht kennen.
/// </summary>
public static readonly string[] VcardFelder =
{
"FN", "N", "ORG", "TITLE", "TEL", "EMAIL", "ADR", "URL", "NOTE", "BDAY"
};
public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string FirstName { get; set; } = "";
@@ -0,0 +1,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
{
@@ -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
+1 -1
View File
@@ -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
+69 -26
View File
@@ -24,10 +24,30 @@ namespace StarfaceOutlookSync.UI
private Label _lblTestResult;
private Label _lblDirectionHint;
private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>();
private List<RemoteAddressBook> _addressBooks = new List<RemoteAddressBook>();
private List<string> _outlookFolderPaths = new List<string>();
private string _selectedOutlookPath = "";
private string _selectedOutlookName = "";
private ComboBox _cmbSystem;
private Label _lblAnlage;
private Label _lblLoginId;
private Label _lblAddressBook;
private string AnlagenName => GewaehlteAnlage == PhoneSystem.Fonaria ? "Fonaria" : "Starface";
private PhoneSystem GewaehlteAnlage =>
_cmbSystem != null && _cmbSystem.SelectedIndex == 1 ? PhoneSystem.Fonaria : PhoneSystem.Starface;
/// <summary>Beschriftungen, die je nach Anlage anders heissen.</summary>
private void UpdateSystemLabels()
{
var fonaria = GewaehlteAnlage == PhoneSystem.Fonaria;
if (_lblLoginId != null)
_lblLoginId.Text = fonaria ? "Benutzername:" : "Login-ID:";
if (_lblAddressBook != null)
_lblAddressBook.Text = fonaria ? "Fonaria-Adressbuch:" : "Starface-Adressbuch:";
UpdateDirectionHint();
}
public ProfileEditorForm(SyncProfile profile)
{
@@ -54,8 +74,26 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Profilname:", 12, y)); y += 22;
_txtName = new TextBox { Left = 12, Top = y, Width = 420 }; panel.Controls.Add(_txtName); y += 32;
// === Starface ===
panel.Controls.Add(MakeSectionLabel("Starface-Verbindung", 12, y)); y += 26;
// === Anlage ===
_lblAnlage = MakeSectionLabel("Verbindung zur Telefonanlage", 12, y); y += 26;
panel.Controls.Add(_lblAnlage);
panel.Controls.Add(MakeLabel("Anlage:", 12, y)); y += 22;
_cmbSystem = new ComboBox { Left = 12, Top = y, Width = 200, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbSystem.Items.AddRange(new object[] { "Starface", "Fonaria" });
_cmbSystem.SelectedIndex = 0;
// Beide sprechen ihre eigene Schnittstelle; der Vorgabeport ist
// derselbe, aber die Adressbuecher heissen anders. Beim Wechsel
// muss die Auswahl darum neu geladen werden.
_cmbSystem.SelectedIndexChanged += (s, e) =>
{
_addressBooks.Clear();
_cmbAddressBook.Items.Clear();
_lblTestResult.Text = "Anlage gewechselt - bitte Adressbuecher neu laden.";
_lblTestResult.ForeColor = Color.FromArgb(150, 80, 0);
UpdateSystemLabels();
};
panel.Controls.Add(_cmbSystem); y += 32;
panel.Controls.Add(MakeLabel("Host / IP-Adresse:", 12, y)); y += 22;
_txtHost = new TextBox { Left = 12, Top = y, Width = 320 };
@@ -67,7 +105,8 @@ namespace StarfaceOutlookSync.UI
_chkSsl = new CheckBox { Text = "HTTPS verwenden", Left = 12, Top = y, Checked = true, AutoSize = true };
panel.Controls.Add(_chkSsl); y += 28;
panel.Controls.Add(MakeLabel("Login-ID:", 12, y)); y += 22;
_lblLoginId = MakeLabel("Login-ID:", 12, y);
panel.Controls.Add(_lblLoginId); y += 22;
_txtLoginId = new TextBox { Left = 12, Top = y, Width = 200 }; panel.Controls.Add(_txtLoginId); y += 32;
panel.Controls.Add(MakeLabel("Kennwort:", 12, y)); y += 22;
@@ -84,7 +123,8 @@ namespace StarfaceOutlookSync.UI
_lblTestResult = new Label { Left = 12, Top = y, Width = 420, Height = 20, ForeColor = Color.Gray };
panel.Controls.Add(_lblTestResult); y += 26;
panel.Controls.Add(MakeLabel("Starface-Adressbuch:", 12, y)); y += 22;
_lblAddressBook = MakeLabel("Adressbuch der Anlage:", 12, y);
panel.Controls.Add(_lblAddressBook); y += 22;
_cmbAddressBook = new ComboBox { Left = 12, Top = y, Width = 420, DropDownStyle = ComboBoxStyle.DropDownList };
panel.Controls.Add(_cmbAddressBook); y += 32;
@@ -100,7 +140,7 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Sync-Richtung:", 12, y)); y += 22;
_cmbDirection = new ComboBox { Left = 12, Top = y, Width = 250, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" });
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Anlage", "Anlage -> Outlook" });
_cmbDirection.SelectedIndex = 0;
panel.Controls.Add(_cmbDirection); y += 28;
@@ -138,11 +178,11 @@ namespace StarfaceOutlookSync.UI
{
switch (_cmbDirection.SelectedIndex)
{
case 1: // Outlook -> Starface
_lblDirectionHint.Text = "Achtung: Das Starface-Adressbuch wird zur exakten Kopie von Outlook.\nKontakte, die nur in Starface existieren, werden geloescht.";
case 1: // Outlook -> Anlage
_lblDirectionHint.Text = $"Achtung: Das Adressbuch der {AnlagenName} wird zur exakten Kopie von Outlook.\nKontakte, die nur dort existieren, werden geloescht.";
break;
case 2: // Starface -> Outlook
_lblDirectionHint.Text = "Achtung: Der Outlook-Ordner wird zur exakten Kopie von Starface.\nKontakte, die nur in Outlook existieren, werden geloescht.";
case 2: // Anlage -> Outlook
_lblDirectionHint.Text = $"Achtung: Der Outlook-Ordner wird zur exakten Kopie der {AnlagenName}.\nKontakte, die nur in Outlook existieren, werden geloescht.";
break;
default: // Bidirektional
_lblDirectionHint.Text = "Aenderungen werden in beide Richtungen abgeglichen.";
@@ -182,15 +222,17 @@ namespace StarfaceOutlookSync.UI
if (_existingProfile != null)
{
_txtName.Text = _existingProfile.Name;
_txtHost.Text = _existingProfile.StarfaceConnection.Host;
_txtPort.Text = _existingProfile.StarfaceConnection.Port.ToString();
_chkSsl.Checked = _existingProfile.StarfaceConnection.UseSsl;
_txtLoginId.Text = _existingProfile.StarfaceConnection.LoginId;
_txtPassword.Text = _existingProfile.StarfaceConnection.Password;
_cmbSystem.SelectedIndex = _existingProfile.Connection.System == PhoneSystem.Fonaria ? 1 : 0;
_txtHost.Text = _existingProfile.Connection.Host;
_txtPort.Text = _existingProfile.Connection.Port.ToString();
_chkSsl.Checked = _existingProfile.Connection.UseSsl;
_txtLoginId.Text = _existingProfile.Connection.LoginId;
_txtPassword.Text = _existingProfile.Connection.Password;
_chkEnabled.Checked = _existingProfile.Enabled;
_numAutoSync.Value = _existingProfile.AutoSyncIntervalMinutes;
_cmbDirection.SelectedIndex = (int)_existingProfile.SyncDirection;
UpdateSystemLabels();
// Outlook-Ordner
_selectedOutlookPath = _existingProfile.OutlookFolderPath;
@@ -198,10 +240,10 @@ namespace StarfaceOutlookSync.UI
_txtOutlookFolder.Text = _selectedOutlookPath;
// Adressbuch
if (_existingProfile.StarfaceAddressBook != null)
if (_existingProfile.AddressBook != null)
{
_addressBooks.Add(_existingProfile.StarfaceAddressBook);
_cmbAddressBook.Items.Add(_existingProfile.StarfaceAddressBook.Name);
_addressBooks.Add(_existingProfile.AddressBook);
_cmbAddressBook.Items.Add(_existingProfile.AddressBook.Name);
_cmbAddressBook.SelectedIndex = 0;
}
}
@@ -227,10 +269,11 @@ namespace StarfaceOutlookSync.UI
}
}
private StarfaceConnection GetConnection()
private SystemConnection GetConnection()
{
return new StarfaceConnection
return new SystemConnection
{
System = GewaehlteAnlage,
Host = _txtHost.Text.Trim(),
Port = int.TryParse(_txtPort.Text, out var p) ? p : 443,
UseSsl = _chkSsl.Checked,
@@ -247,7 +290,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (ok)
@@ -280,7 +323,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (!ok)
@@ -350,8 +393,8 @@ namespace StarfaceOutlookSync.UI
{
Id = _existingProfile?.Id ?? _pm.GenerateId(),
Name = _txtName.Text.Trim(),
StarfaceConnection = GetConnection(),
StarfaceAddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
Connection = GetConnection(),
AddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
OutlookFolderPath = _selectedOutlookPath,
OutlookFolderName = _selectedOutlookName,
SyncDirection = (SyncDirection)_cmbDirection.SelectedIndex,
@@ -367,8 +410,8 @@ namespace StarfaceOutlookSync.UI
else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.StarfaceAddressBook?.TagId != profile.StarfaceAddressBook?.TagId
|| _existingProfile.StarfaceAddressBook?.Type != profile.StarfaceAddressBook?.Type)
if (_existingProfile.AddressBook?.TagId != profile.AddressBook?.TagId
|| _existingProfile.AddressBook?.Type != profile.AddressBook?.Type)
{
_pm.SaveMappings(profile.Id, new List<SyncMapping>());
profile.LastSync = "";
@@ -35,7 +35,7 @@ namespace StarfaceOutlookSync.UI
var infoLabel = new Label
{
Text = $"{_profile.StarfaceConnection.Host} ({_profile.StarfaceAddressBook.Name}) <-> {_profile.OutlookFolderName}",
Text = $"{_profile.Connection.Host} ({_profile.AddressBook.Name}) <-> {_profile.OutlookFolderName}",
Left = 12, Top = 12, Width = 460, AutoSize = false, Height = 20
};