Compare commits

...

15 Commits

Author SHA1 Message Date
duffyduck 2b9ad5bf3c Release v0.0.0.14 2026-04-03 18:13:48 +02:00
duffyduck 8a316600b5 Add debug logging for Starface create/update failures
Log the full request body and response when POST /contacts fails
so we can see why new contacts are not being created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:12:26 +02:00
duffyduck 0f61b4cd31 Release v0.0.0.13 2026-04-03 18:04:40 +02:00
duffyduck b9f5a26b27 Start progress bar only when sync begins, not on form open
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:07 +02:00
duffyduck 4359d3ad1a Add Sync Reset button to clear mappings for a profile
Deletes all contact mappings and resets LastSync timestamp.
Next sync will re-match all contacts fresh without creating
duplicates (thanks to the duplicate detection in sync engine).
No contacts are deleted on either side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:02:25 +02:00
duffyduck 39b30f9a80 Rewrite sync engine for robustness and duplicate prevention
Three-phase sync approach:
1. Process existing mappings (detect changes on both sides,
   handle conflicts with configurable winner)
2. Sync unmapped Outlook contacts to Starface with duplicate
   check (match by email, name+company, name, phone)
3. Sync unmapped Starface contacts to Outlook with duplicate check

Key improvements:
- Duplicate detection before creating: checks email, name+company,
  name, and phone number with normalization
- Matched duplicates get linked instead of re-created
- Conflict resolution when both sides changed
- Dead mappings (both sides deleted) get cleaned up
- Each contact logged individually with direction indicator
- Address book switch works: old mappings get cleaned, contacts
  re-matched against new book

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:00:41 +02:00
duffyduck f0bcfdfd30 Release v0.0.0.12 2026-04-03 12:55:53 +02:00
duffyduck 650d0def51 Fix Starface contact field mapping based on actual API response
Read mapping: Match by 'name' field instead of 'displayKey' because
many fields use USER_DEFINED as displayKey (street, city, state,
comment). Actual name fields: firstname, familyname, company, phone,
mobile, homephone, fax, e-mail, url, comment, street, city, postcode,
state, country.

Write mapping: Use correct 4-block structure (contact, address,
telephone, email) with proper resourceKey values matching the
Starface internal format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:54:47 +02:00
duffyduck f57518a1d5 Release v0.0.0.11 2026-04-03 12:51:14 +02:00
duffyduck 1be0a94b51 Fetch full contact details from Starface instead of summary
The contacts list endpoint only returns summaryValues/phoneNumbers.
Now fetch each contact individually via GET /contacts/{id} to get
all fields (blocks/attributes). Also log the detail JSON structure
so we can verify the field mapping is correct.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:50:16 +02:00
duffyduck 3c38a1a6cc Release v0.0.0.10 2026-04-03 12:17:30 +02:00
duffyduck ba0b79de64 Release v0.0.0.9 2026-04-03 12:14:42 +02:00
duffyduck 60bd3163a9 Add debug logging for raw Starface API contact data
Logs the first contact from Starface API response into the sync
log so we can see the actual JSON structure and fix field mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:13:43 +02:00
duffyduck a3a3ac1dcc Release v0.0.0.8 2026-04-03 12:10:35 +02:00
duffyduck 9d58a1a113 Fix Starface JSON parsing and Outlook folder/contact reading
Starface API:
- Handle both array and object responses from /contacts endpoint
- Try common wrapper fields (items, contacts, data, results)

Outlook contacts:
- Add FindFolderByPath that matches exact FolderPath property
- Fallback to Namespace.Folders navigation if store lookup fails
- Fallback: try reading contact fields even if Class != 40
- Add debug logging for folder path and item count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:08:50 +02:00
8 changed files with 564 additions and 205 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php) ; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
#define MyAppName "Starface Outlook Sync" #define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.7" #define MyAppVersion "0.0.0.14"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation" #define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de" #define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe" #define MyAppExeName "StarfaceOutlookSync.exe"
@@ -153,6 +153,32 @@ namespace StarfaceOutlookSync.Services
} }
} }
private dynamic FindFolderByPath(dynamic folder, string targetPath)
{
try
{
string currentPath = folder.FolderPath;
if (currentPath == targetPath)
return folder;
var subs = folder.Folders;
for (int i = 1; i <= (int)subs.Count; i++)
{
try
{
var sub = subs[i];
var match = FindFolderByPath(sub, targetPath);
if (match != null) return match;
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subs);
}
catch { }
return null;
}
private dynamic GetFolderByPath(string folderPath) private dynamic GetFolderByPath(string folderPath)
{ {
var app = GetOutlookApp(); var app = GetOutlookApp();
@@ -163,51 +189,69 @@ namespace StarfaceOutlookSync.Services
try try
{ {
var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries); // Versuch 1: Alle Kontaktordner durchsuchen und per FolderPath matchen
dynamic current = null;
var stores = ns.Stores; var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++) for (int i = 1; i <= (int)stores.Count; i++)
{ {
var store = stores[i]; try
var root = store.GetRootFolder();
string rootName = root.Name;
if (rootName == parts[0])
{ {
current = root; var store = stores[i];
break; var root = store.GetRootFolder();
var match = FindFolderByPath(root, folderPath);
if (match != null)
{
Marshal.ReleaseComObject(stores);
return match;
}
Marshal.ReleaseComObject(root);
Marshal.ReleaseComObject(store);
} }
Marshal.ReleaseComObject(root); catch { }
Marshal.ReleaseComObject(store);
} }
Marshal.ReleaseComObject(stores); Marshal.ReleaseComObject(stores);
if (current == null) // Versuch 2: Namespace.Folders direkt navigieren
return ns.GetDefaultFolder(OlFolderContacts); var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 1)
for (int i = 1; i < parts.Length; i++)
{ {
bool found = false; var topFolders = ns.Folders;
var subFolders = current.Folders; for (int i = 1; i <= (int)topFolders.Count; i++)
for (int j = 1; j <= (int)subFolders.Count; j++)
{ {
var sub = subFolders[j]; var topFolder = topFolders[i];
if ((string)sub.Name == parts[i]) if ((string)topFolder.Name == parts[0])
{ {
current = sub; dynamic current = topFolder;
found = true; for (int p = 1; p < parts.Length; p++)
break; {
bool found = false;
var subs = current.Folders;
for (int j = 1; j <= (int)subs.Count; j++)
{
var sub = subs[j];
if ((string)sub.Name == parts[p])
{
current = sub;
found = true;
break;
}
Marshal.ReleaseComObject(sub);
}
Marshal.ReleaseComObject(subs);
if (!found) { current = null; break; }
}
if (current != null)
{
Marshal.ReleaseComObject(topFolders);
return current;
}
} }
Marshal.ReleaseComObject(sub); Marshal.ReleaseComObject(topFolder);
} }
Marshal.ReleaseComObject(subFolders); Marshal.ReleaseComObject(topFolders);
if (!found)
return ns.GetDefaultFolder(OlFolderContacts);
} }
return current; System.Diagnostics.Debug.WriteLine($"Folder not found by path: {folderPath}, using default");
return ns.GetDefaultFolder(OlFolderContacts);
} }
catch catch
{ {
@@ -221,24 +265,51 @@ namespace StarfaceOutlookSync.Services
try try
{ {
var folder = GetFolderByPath(folderPath); var folder = GetFolderByPath(folderPath);
var items = folder.Items; System.Diagnostics.Debug.WriteLine($"Reading contacts from: {(string)folder.FolderPath}");
for (int i = 1; i <= (int)items.Count; i++) var items = folder.Items;
int count = (int)items.Count;
System.Diagnostics.Debug.WriteLine($"Items count: {count}");
for (int i = 1; i <= count; i++)
{ {
dynamic item = null; dynamic item = null;
try try
{ {
item = items[i]; item = items[i];
// Nur ContactItems verarbeiten (Class = 40 = olContact) int itemClass = -1;
if ((int)item.Class == 40) try { itemClass = (int)item.Class; } catch { }
// olContact = 40, aber manche Kontakte melden Class anders
// Versuch einfach die Kontakt-Felder zu lesen
if (itemClass == 40)
{ {
contacts.Add(MapFromOutlook(item)); contacts.Add(MapFromOutlook(item));
} }
else
{
// Fallback: Pruefen ob es trotzdem ein Kontakt ist
try
{
string eid = item.EntryID;
string fn = item.FirstName;
string ln = item.LastName;
// Hat Kontakt-Felder -> ist ein Kontakt
contacts.Add(MapFromOutlook(item));
}
catch
{
// Kein Kontakt, ueberspringen
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error reading item {i}: {ex.Message}");
} }
catch { }
finally finally
{ {
if (item != null) Marshal.ReleaseComObject(item); if (item != null) try { Marshal.ReleaseComObject(item); } catch { }
} }
} }
@@ -155,11 +155,14 @@ namespace StarfaceOutlookSync.Services
return books; return books;
} }
public event Action<string> OnDebug;
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book) public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
{ {
var contacts = new List<UnifiedContact>(); var contacts = new List<UnifiedContact>();
int page = 0; int page = 0;
const int pageSize = 200; const int pageSize = 200;
bool firstPage = true;
while (true) while (true)
{ {
@@ -172,11 +175,68 @@ namespace StarfaceOutlookSync.Services
var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}"); var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}");
if (!resp.IsSuccessStatusCode) break; if (!resp.IsSuccessStatusCode) break;
var array = JArray.Parse(await resp.Content.ReadAsStringAsync()); var body = await resp.Content.ReadAsStringAsync();
JArray array;
// Die API gibt je nach Version ein Array oder ein Objekt mit "items" zurueck
var token = JToken.Parse(body);
if (token is JArray directArray)
{
array = directArray;
}
else if (token is JObject obj)
{
// Versuche gaengige Felder: items, contacts, data, results
array = (obj["items"] ?? obj["contacts"] ?? obj["data"] ?? obj["results"]) as JArray;
if (array == null)
{
// Einzelnes Kontakt-Objekt? Dann in Array wrappen
if (obj["id"] != null && obj["blocks"] != null)
{
array = new JArray { obj };
}
else
{
System.Diagnostics.Debug.WriteLine($"Unerwartete Starface-Antwort: {body.Substring(0, Math.Min(200, body.Length))}");
break;
}
}
}
else
{
break;
}
if (array.Count == 0) break; if (array.Count == 0) break;
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
// Die Listen-API gibt nur Summary zurueck.
// Jeden Kontakt einzeln abrufen fuer alle Felder.
foreach (var item in array) foreach (var item in array)
contacts.Add(MapFromStarface(item)); {
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
try
{
var detailResp = await _http.GetAsync($"{_baseUrl}/contacts/{id}");
if (detailResp.IsSuccessStatusCode)
{
var detailBody = await detailResp.Content.ReadAsStringAsync();
var detailObj = JObject.Parse(detailBody);
if (firstPage)
{
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{detailObj.ToString(Formatting.Indented)}");
firstPage = false;
}
contacts.Add(MapFromStarface(detailObj));
}
}
catch { }
}
if (array.Count < pageSize) break; if (array.Count < pageSize) break;
page++; page++;
@@ -192,11 +252,20 @@ namespace StarfaceOutlookSync.Services
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId)) if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}"; query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json"); var body = sfContact.ToString();
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content); OnDebug?.Invoke($"POST /contacts{query} Body:\n{body}");
if (!resp.IsSuccessStatusCode) return null;
var created = JObject.Parse(await resp.Content.ReadAsStringAsync()); var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"POST /contacts fehlgeschlagen: {(int)resp.StatusCode} {resp.StatusCode}\n{respBody}");
return null;
}
var created = JObject.Parse(respBody);
return MapFromStarface(created); return MapFromStarface(created);
} }
@@ -224,7 +293,10 @@ namespace StarfaceOutlookSync.Services
var contact = new UnifiedContact(); var contact = new UnifiedContact();
contact.StarfaceId = item["id"]?.ToString() ?? ""; contact.StarfaceId = item["id"]?.ToString() ?? "";
var attrs = new Dictionary<string, string>(); // Attribute per "name"-Feld mappen (zuverlaessiger als displayKey,
// weil viele Felder USER_DEFINED als displayKey haben)
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byDisplayKey = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var blocks = item["blocks"] as JArray; var blocks = item["blocks"] as JArray;
if (blocks != null) if (blocks != null)
{ {
@@ -234,79 +306,123 @@ namespace StarfaceOutlookSync.Services
if (blockAttrs == null) continue; if (blockAttrs == null) continue;
foreach (var attr in blockAttrs) foreach (var attr in blockAttrs)
{ {
var key = attr["displayKey"]?.ToString() ?? ""; var name = attr["name"]?.ToString() ?? "";
var displayKey = attr["displayKey"]?.ToString() ?? "";
var val = attr["value"]?.ToString() ?? ""; var val = attr["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(val)) if (!string.IsNullOrEmpty(val))
attrs[key] = val; {
if (!string.IsNullOrEmpty(name))
byName[name] = val;
// displayKey nur als Fallback (viele sind USER_DEFINED)
if (!string.IsNullOrEmpty(displayKey) && displayKey != "USER_DEFINED")
byDisplayKey[displayKey] = val;
}
} }
} }
} }
contact.FirstName = attrs.GetValueOrDefault("NAME", ""); // Primaer nach name-Feld mappen, Fallback auf displayKey
contact.LastName = attrs.GetValueOrDefault("SURNAME", ""); string Get(string name, string displayKey = null)
contact.Company = attrs.GetValueOrDefault("COMPANY", ""); {
contact.JobTitle = attrs.GetValueOrDefault("JOB_TITLE", ""); if (byName.TryGetValue(name, out var v)) return v;
contact.Email = attrs.GetValueOrDefault("EMAIL", ""); if (displayKey != null && byDisplayKey.TryGetValue(displayKey, out v)) return v;
contact.PhoneWork = attrs.GetValueOrDefault("OFFICE_PHONE_NUMBER", ""); return "";
contact.PhoneMobile = attrs.GetValueOrDefault("MOBILE_PHONE_NUMBER", ""); }
contact.PhoneHome = attrs.GetValueOrDefault("PRIVATE_PHONE_NUMBER", "");
contact.Fax = attrs.GetValueOrDefault("FAX_NUMBER", ""); contact.FirstName = Get("firstname", "NAME");
contact.Street = attrs.GetValueOrDefault("STREET", ""); contact.LastName = Get("familyname", "SURNAME");
contact.City = attrs.GetValueOrDefault("CITY", ""); contact.Company = Get("company", "COMPANY");
contact.PostalCode = attrs.GetValueOrDefault("POSTAL_CODE", ""); contact.JobTitle = Get("jobtitle", "JOB_TITLE");
contact.State = attrs.GetValueOrDefault("STATE", ""); contact.Email = Get("e-mail", "EMAIL");
contact.Country = attrs.GetValueOrDefault("COUNTRY", ""); contact.PhoneWork = Get("phone", "PHONE_NUMBER");
contact.Website = attrs.GetValueOrDefault("URL", ""); contact.PhoneMobile = Get("mobile", "MOBILE_PHONE_NUMBER");
contact.Notes = attrs.GetValueOrDefault("NOTE", ""); contact.PhoneHome = Get("homephone", "PRIVATE_PHONE_NUMBER");
contact.Salutation = attrs.GetValueOrDefault("SALUTATION", ""); contact.Fax = Get("fax", "FAX_NUMBER");
contact.Title = attrs.GetValueOrDefault("TITLE", ""); contact.Street = Get("street", "STREET");
contact.Birthday = attrs.GetValueOrDefault("BIRTHDAY", ""); contact.City = Get("city", "CITY");
contact.PostalCode = Get("postcode", "POSTAL_CODE");
contact.State = Get("state", "STATE");
contact.Country = Get("country", "COUNTRY");
contact.Website = Get("url", "URL");
contact.Notes = Get("comment", "NOTE");
contact.Salutation = Get("salutation", "SALUTATION");
contact.Title = Get("title", "TITLE");
contact.Birthday = Get("birthday", "BIRTHDAY");
return contact; return contact;
} }
private JObject MapToStarface(UnifiedContact contact) private JObject MapToStarface(UnifiedContact contact)
{ {
var attrs = new JArray(); JArray MakeAttrs(params (string displayKey, string name, string value)[] fields)
void AddAttr(string displayKey, string name, string value)
{ {
if (!string.IsNullOrEmpty(value)) var arr = new JArray();
attrs.Add(new JObject { ["displayKey"] = displayKey, ["name"] = name, ["value"] = value }); foreach (var (dk, n, v) in fields)
{
if (!string.IsNullOrEmpty(v))
arr.Add(new JObject { ["displayKey"] = dk, ["name"] = n, ["value"] = v });
}
return arr;
} }
AddAttr("NAME", "firstName", contact.FirstName); // Block-Struktur wie von der Starface erwartet
AddAttr("SURNAME", "lastName", contact.LastName); var contactBlock = MakeAttrs(
AddAttr("COMPANY", "company", contact.Company); ("NAME", "firstname", contact.FirstName),
AddAttr("JOB_TITLE", "jobTitle", contact.JobTitle); ("SURNAME", "familyname", contact.LastName),
AddAttr("EMAIL", "email", contact.Email); ("COMPANY", "company", contact.Company)
AddAttr("OFFICE_PHONE_NUMBER", "businessPhone", contact.PhoneWork); );
AddAttr("MOBILE_PHONE_NUMBER", "mobilePhone", contact.PhoneMobile);
AddAttr("PRIVATE_PHONE_NUMBER", "homePhone", contact.PhoneHome); var addressBlock = MakeAttrs(
AddAttr("FAX_NUMBER", "fax", contact.Fax); ("USER_DEFINED", "street", contact.Street),
AddAttr("STREET", "street", contact.Street); ("POSTAL_CODE", "postcode", contact.PostalCode),
AddAttr("CITY", "city", contact.City); ("USER_DEFINED", "city", contact.City),
AddAttr("POSTAL_CODE", "postalCode", contact.PostalCode); ("USER_DEFINED", "state", contact.State),
AddAttr("STATE", "state", contact.State); ("USER_DEFINED", "country", contact.Country)
AddAttr("COUNTRY", "country", contact.Country); );
AddAttr("URL", "website", contact.Website);
AddAttr("NOTE", "notes", contact.Notes); var phoneBlock = MakeAttrs(
AddAttr("SALUTATION", "salutation", contact.Salutation); ("PHONE_NUMBER", "phone", contact.PhoneWork),
AddAttr("TITLE", "title", contact.Title); ("PRIVATE_PHONE_NUMBER", "homephone", contact.PhoneHome),
AddAttr("BIRTHDAY", "birthday", contact.Birthday); ("MOBILE_PHONE_NUMBER", "mobile", contact.PhoneMobile),
("FAX_NUMBER", "fax", contact.Fax)
);
var emailBlock = MakeAttrs(
("EMAIL", "e-mail", contact.Email),
("URL", "url", contact.Website),
("USER_DEFINED", "comment", contact.Notes)
);
var blocks = new JArray();
blocks.Add(new JObject
{
["name"] = "contact",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_contact",
["attributes"] = contactBlock
});
blocks.Add(new JObject
{
["name"] = "address",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_address",
["attributes"] = addressBlock
});
blocks.Add(new JObject
{
["name"] = "telephone",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_telephone",
["attributes"] = phoneBlock
});
blocks.Add(new JObject
{
["name"] = "email",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_email",
["attributes"] = emailBlock
});
return new JObject return new JObject
{ {
["id"] = contact.StarfaceId ?? "", ["id"] = contact.StarfaceId ?? "",
["blocks"] = new JArray ["blocks"] = blocks
{
new JObject
{
["name"] = "contact",
["resourceKey"] = "contact",
["attributes"] = attrs
}
}
}; };
} }
+236 -98
View File
@@ -15,9 +15,15 @@ namespace StarfaceOutlookSync.Services
private void Log(string message) => OnProgress?.Invoke(message); private void Log(string message) => OnProgress?.Invoke(message);
/// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// Matching-Reihenfolge: E-Mail, dann Vorname+Nachname+Firma, dann Vorname+Nachname.
/// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates) private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{ {
// Erst E-Mail-Match if (candidates == null || candidates.Count == 0) return null;
// 1. Exakte E-Mail
if (!string.IsNullOrEmpty(contact.Email)) if (!string.IsNullOrEmpty(contact.Email))
{ {
var byEmail = candidates.FirstOrDefault(c => var byEmail = candidates.FirstOrDefault(c =>
@@ -26,7 +32,18 @@ namespace StarfaceOutlookSync.Services
if (byEmail != null) return byEmail; if (byEmail != null) return byEmail;
} }
// Dann Name-Match // 2. Vorname + Nachname + Firma (staerkstes Match ohne E-Mail)
if ((!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
&& !string.IsNullOrEmpty(contact.Company))
{
var byNameCompany = candidates.FirstOrDefault(c =>
c.FirstName.Equals(contact.FirstName, StringComparison.OrdinalIgnoreCase) &&
c.LastName.Equals(contact.LastName, StringComparison.OrdinalIgnoreCase) &&
c.Company.Equals(contact.Company, StringComparison.OrdinalIgnoreCase));
if (byNameCompany != null) return byNameCompany;
}
// 3. Vorname + Nachname (ohne Firma)
if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName)) if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
{ {
var byName = candidates.FirstOrDefault(c => var byName = candidates.FirstOrDefault(c =>
@@ -36,9 +53,32 @@ namespace StarfaceOutlookSync.Services
if (byName != null) return byName; if (byName != null) return byName;
} }
// 4. Telefonnummer (Buero oder Mobil)
if (!string.IsNullOrEmpty(contact.PhoneWork))
{
var byPhone = candidates.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.PhoneWork) &&
NormalizePhone(c.PhoneWork) == NormalizePhone(contact.PhoneWork));
if (byPhone != null) return byPhone;
}
if (!string.IsNullOrEmpty(contact.PhoneMobile))
{
var byMobile = candidates.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.PhoneMobile) &&
NormalizePhone(c.PhoneMobile) == NormalizePhone(contact.PhoneMobile));
if (byMobile != null) return byMobile;
}
return null; return null;
} }
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile) public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{ {
var result = new SyncResult var result = new SyncResult
@@ -49,10 +89,10 @@ namespace StarfaceOutlookSync.Services
try try
{ {
// Starface verbinden
Log("Verbinde mit Starface..."); Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection)) using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
{ {
starface.OnDebug += (msg) => Log(msg);
var loginOk = await starface.LoginAsync(); var loginOk = await starface.LoginAsync();
if (!loginOk) if (!loginOk)
{ {
@@ -61,10 +101,6 @@ namespace StarfaceOutlookSync.Services
return result; return result;
} }
var mappings = _profileManager.GetMappings(profile.Id);
var mappingByOutlook = mappings.ToDictionary(m => m.OutlookEntryId, m => m);
var mappingByStarface = mappings.ToDictionary(m => m.StarfaceId, m => m);
// Kontakte laden // Kontakte laden
Log("Lade Outlook-Kontakte..."); Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath); var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
@@ -74,144 +110,246 @@ namespace StarfaceOutlookSync.Services
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook); var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen"); Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Outlook -> Starface // Bestehende Mappings laden
if (profile.SyncDirection == SyncDirection.Both || var mappings = _profileManager.GetMappings(profile.Id);
profile.SyncDirection == SyncDirection.OutlookToStarface)
{
Log("Synchronisiere Outlook -> Starface...");
foreach (var oc in outlookContacts)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(oc.OutlookEntryId))
mappingByOutlook.TryGetValue(oc.OutlookEntryId, out existing);
if (existing != null) // Sets fuer schnellen Lookup
var mappingByOutlook = new Dictionary<string, SyncMapping>();
var mappingByStarface = new Dictionary<string, SyncMapping>();
foreach (var m in mappings)
{
if (!string.IsNullOrEmpty(m.OutlookEntryId))
mappingByOutlook[m.OutlookEntryId] = m;
if (!string.IsNullOrEmpty(m.StarfaceId))
mappingByStarface[m.StarfaceId] = m;
}
// Tracking: welche Kontakte wurden bereits verarbeitet
var processedStarfaceIds = new HashSet<string>();
var processedOutlookIds = new HashSet<string>();
var newMappings = new List<SyncMapping>();
// ============================================
// Phase 1: Bestehende Mappings abgleichen
// ============================================
Log("Gleiche bestehende Zuordnungen ab...");
foreach (var mapping in mappings.ToList())
{
var oc = outlookContacts.FirstOrDefault(c => c.OutlookEntryId == mapping.OutlookEntryId);
var sc = starfaceContacts.FirstOrDefault(c => c.StarfaceId == mapping.StarfaceId);
if (oc != null) processedOutlookIds.Add(oc.OutlookEntryId);
if (sc != null) processedStarfaceIds.Add(sc.StarfaceId);
if (oc == null && sc == null)
{
// Beide Seiten geloescht -> Mapping entfernen
continue;
}
if (oc != null && sc != null)
{
// Beide vorhanden -> auf Aenderungen pruefen
var olHash = oc.GetHash();
var sfHash = sc.GetHash();
bool olChanged = olHash != mapping.LastSyncHash;
bool sfChanged = sfHash != mapping.LastSyncHash;
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{
// Outlook hat sich geaendert -> Starface updaten
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{ {
var hash = oc.GetHash(); mapping.LastSyncHash = olHash;
if (hash != existing.LastSyncHash) result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
}
}
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{
// Starface hat sich geaendert -> Outlook updaten
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
{
mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
}
}
else if (olChanged && sfChanged)
{
// Beide geaendert -> Konflikt, neuere gewinnt (Outlook bevorzugt)
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{ {
if (await starface.UpdateContactAsync(existing.StarfaceId, oc, profile.StarfaceAddressBook)) mapping.LastSyncHash = olHash;
{ result.Updated++;
existing.LastSyncHash = hash; Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
result.Updated++;
}
} }
} }
else else
{ {
var match = FindMatch(oc, starfaceContacts); if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
if (match != null && !string.IsNullOrEmpty(match.StarfaceId))
{ {
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook)) mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
}
}
}
// Beide unveraendert -> nichts tun
}
newMappings.Add(mapping);
}
// ============================================
// Phase 2: Neue Outlook-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
if (unmappedOutlook.Count > 0)
Log($"Neue Outlook-Kontakte: {unmappedOutlook.Count}");
// Starface-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
foreach (var oc in unmappedOutlook)
{
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook))
{
newMappings.Add(new SyncMapping
{ {
_profileManager.AddOrUpdateMapping(new SyncMapping ProfileId = profile.Id,
{ OutlookEntryId = oc.OutlookEntryId,
ProfileId = profile.Id, StarfaceId = match.StarfaceId,
OutlookEntryId = oc.OutlookEntryId, LastSyncHash = oc.GetHash()
StarfaceId = match.StarfaceId, });
LastSyncHash = oc.GetHash() processedStarfaceIds.Add(match.StarfaceId);
}); unmappedStarface.Remove(match);
result.Updated++; result.Updated++;
} Log($" Verknuepft (OL->SF): {oc.DisplayName}");
}
}
else
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}");
} }
else else
{ {
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook); Log($" FEHLER: Kontakt konnte nicht erstellt werden: {oc.DisplayName}");
if (created != null && !string.IsNullOrEmpty(created.StarfaceId)) result.Errors++;
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Created++;
}
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
result.Errors++; result.Errors++;
result.ErrorMessages.Add($"{oc.DisplayName}: {ex.Message}"); result.ErrorMessages.Add($"OL->SF {oc.DisplayName}: {ex.Message}");
} }
} }
} }
// Starface -> Outlook // ============================================
if (profile.SyncDirection == SyncDirection.Both || // Phase 3: Neue Starface-Kontakte (ohne Mapping)
profile.SyncDirection == SyncDirection.StarfaceToOutlook) // ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{ {
Log("Synchronisiere Starface -> Outlook..."); var unmappedStarface = starfaceContacts
foreach (var sc in starfaceContacts) .Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
if (unmappedStarface.Count > 0)
Log($"Neue Starface-Kontakte: {unmappedStarface.Count}");
// Outlook-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
foreach (var sc in unmappedStarface)
{ {
try try
{ {
SyncMapping existing = null; // Duplikat-Check: existiert der Kontakt schon in Outlook?
if (!string.IsNullOrEmpty(sc.StarfaceId)) var match = FindMatch(sc, unmappedOutlook);
mappingByStarface.TryGetValue(sc.StarfaceId, out existing); if (match != null)
if (existing != null)
{ {
var hash = sc.GetHash(); // Existiert schon -> verknuepfen und updaten
if (hash != existing.LastSyncHash) if (_outlookService.UpdateContact(match.OutlookEntryId, sc))
{ {
if (_outlookService.UpdateContact(existing.OutlookEntryId, sc)) newMappings.Add(new SyncMapping
{ {
existing.LastSyncHash = hash; ProfileId = profile.Id,
result.Updated++; OutlookEntryId = match.OutlookEntryId,
} StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match);
result.Updated++;
Log($" Verknuepft (SF->OL): {sc.DisplayName}");
} }
} }
else else
{ {
var match = FindMatch(sc, outlookContacts); // Neu -> in Outlook erstellen
if (match != null && !string.IsNullOrEmpty(match.OutlookEntryId)) var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{ {
if (_outlookService.UpdateContact(match.OutlookEntryId, sc)) newMappings.Add(new SyncMapping
{ {
_profileManager.AddOrUpdateMapping(new SyncMapping ProfileId = profile.Id,
{ OutlookEntryId = created.OutlookEntryId,
ProfileId = profile.Id, StarfaceId = sc.StarfaceId,
OutlookEntryId = match.OutlookEntryId, LastSyncHash = sc.GetHash()
StarfaceId = sc.StarfaceId, });
LastSyncHash = sc.GetHash() result.Created++;
}); Log($" Erstellt (SF->OL): {sc.DisplayName}");
result.Updated++;
}
}
else
{
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Created++;
}
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
result.Errors++; result.Errors++;
result.ErrorMessages.Add($"{sc.DisplayName}: {ex.Message}"); result.ErrorMessages.Add($"SF->OL {sc.DisplayName}: {ex.Message}");
} }
} }
} }
// Mappings speichern
_profileManager.SaveMappings(profile.Id, newMappings);
_profileManager.UpdateLastSync(profile.Id); _profileManager.UpdateLastSync(profile.Id);
_profileManager.SaveMappings(profile.Id, mappings);
await starface.LogoutAsync(); await starface.LogoutAsync();
Log("Synchronisation abgeschlossen!");
Log($"Fertig: {result.Created} erstellt, {result.Updated} aktualisiert, {result.Errors} Fehler");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -7,9 +7,9 @@
<AssemblyTitle>Starface Outlook Sync</AssemblyTitle> <AssemblyTitle>Starface Outlook Sync</AssemblyTitle>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company> <Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product> <Product>Starface Outlook Sync</Product>
<Version>0.0.0.7</Version> <Version>0.0.0.14</Version>
<AssemblyVersion>0.0.0.7</AssemblyVersion> <AssemblyVersion>0.0.0.14</AssemblyVersion>
<FileVersion>0.0.0.7</FileVersion> <FileVersion>0.0.0.14</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description> <Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright> <Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+1 -1
View File
@@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label var lblVersion = new Label
{ {
Text = "Version 0.0.0.7", Text = "Version 0.0.0.14",
Left = 0, Top = 56, Width = 340, Height = 20, Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter, TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray ForeColor = Color.Gray
+37 -4
View File
@@ -18,7 +18,7 @@ namespace StarfaceOutlookSync.UI
private NotifyIcon _trayIcon; private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu; private ContextMenuStrip _trayMenu;
private ListView _profileList; private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo; private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo;
private StatusStrip _statusBar; private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel; private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer; private Timer _autoSyncTimer;
@@ -97,16 +97,19 @@ namespace StarfaceOutlookSync.UI
_btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 }; _btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 };
_btnDelete.Click += (s, e) => DeleteProfile(); _btnDelete.Click += (s, e) => DeleteProfile();
_btnSync = new Button { Text = "Jetzt synchronisieren", Width = 150, Height = 30 }; _btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile(); _btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnSettings = new Button { Text = "Einstellungen", Width = 100, Height = 30 }; _btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 };
_btnReset.Click += (s, e) => ResetSync();
_btnSettings = new Button { Text = "Einstellungen", Width = 95, Height = 30 };
_btnSettings.Click += (s, e) => ShowSettings(); _btnSettings.Click += (s, e) => ShowSettings();
_btnInfo = new Button { Text = "Info", Width = 50, Height = 30 }; _btnInfo = new Button { Text = "Info", Width = 50, Height = 30 };
_btnInfo.Click += (s, e) => ShowAbout(); _btnInfo.Click += (s, e) => ShowAbout();
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo }); buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo });
// Statusbar // Statusbar
_statusBar = new StatusStrip(); _statusBar = new StatusStrip();
@@ -255,6 +258,36 @@ namespace StarfaceOutlookSync.UI
} }
} }
private void ResetSync()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync Reset",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
var msg = $"Sync-Zuordnungen fuer '{profile.Name}' zuruecksetzen?\n\n" +
"Alle Kontakt-Verknuepfungen werden geloescht.\n" +
"Beim naechsten Sync werden die Kontakte neu abgeglichen.\n" +
"Es werden keine Kontakte geloescht.";
if (MessageBox.Show(msg, "Sync Reset",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
{
_profileManager.SaveMappings(profile.Id, new List<SyncMapping>());
// LastSync auch zuruecksetzen
profile.LastSync = "";
_profileManager.UpdateProfile(profile);
RefreshProfileList();
MessageBox.Show("Sync-Zuordnungen wurden zurueckgesetzt.",
"Sync Reset", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private Task SyncSelectedProfile() private Task SyncSelectedProfile()
{ {
if (_profileList.SelectedItems.Count == 0) if (_profileList.SelectedItems.Count == 0)
@@ -41,7 +41,8 @@ namespace StarfaceOutlookSync.UI
_progressBar = new ProgressBar _progressBar = new ProgressBar
{ {
Left = 12, Top = 38, Width = 460, Height = 22, Left = 12, Top = 38, Width = 460, Height = 22,
Style = ProgressBarStyle.Marquee Style = ProgressBarStyle.Blocks,
Value = 0
}; };
_txtLog = new TextBox _txtLog = new TextBox