Compare commits

...

11 Commits

Author SHA1 Message Date
duffyduck ad323c9d0f Release v0.0.0.24 2026-06-08 09:58:26 +02:00
duffyduck 849a996b9a Fix sync duplicates and extreme slowness
Behebt Dubletten auf beiden Seiten und sehr langsame Syncs:

- Getrennte Hash-Baselines pro Seite (LastOutlookHash/LastStarfaceHash)
  statt eines gemeinsamen Hashes. Outlook und Starface stellen denselben
  Kontakt unterschiedlich dar, wodurch der gemeinsame Hash nie passte und
  bei jedem Lauf praktisch jeder Kontakt neu geschrieben wurde.
- Update-Methoden geben den frisch eingelesenen Stand zurueck, damit die
  Baseline nach dem Schreiben korrekt gesetzt wird (sauberes Konvergieren).
- Unvollstaendig geladene Starface-Liste bricht jetzt mit Fehler ab
  (inkl. Retry) statt still mit Teil-Liste weiterzuarbeiten - das liess
  Kontakte faelschlich als geloescht erscheinen und erzeugte Dubletten.
- Fehlender Starface-Kontakt (anderes Adressbuch) behaelt das Mapping,
  statt es zu verwerfen und neu anzulegen.
- Lockereres Re-Matching: gleicher E-Mail- oder voller Namens-Treffer
  reicht; umformatierte Telefonnummern blockieren ihn nicht mehr.
- Starface-Kontaktdetails werden parallel geladen (8 gleichzeitig).

Bestehende Mappings werden beim ersten Sync automatisch migriert.
CHANGELOG.md hinzugefuegt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:57:37 +02:00
duffyduck b07a3b3a87 Fix critical delete bug: don't mass-delete on EntryID change
Two fixes for delete propagation in Phase 1:

1. When Outlook contact not found by EntryID, try to re-match
   by name/email/phone before assuming it was deleted. Outlook
   can change EntryIDs on restart or profile changes, causing
   the sync to think ALL contacts were deleted.

2. When Starface contact not found in current list, DON'T delete
   from Outlook. The contact may belong to a different address
   book. Just drop the mapping and let Phase 2/3 re-link it.

These changes make delete propagation much safer and prevent
accidental mass-deletion of contacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:03:51 +02:00
duffyduck f620e96d23 Document Outlook security prompt suppression in README
Include step-by-step instructions for both standalone PCs and
domain/Terminal Server environments where GPO locks the setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:55:41 +02:00
duffyduck e8731a87d4 Release v0.0.0.23 2026-04-03 19:50:03 +02:00
duffyduck 53ca4611d1 Write Outlook security keys to HKLM for domain environments
On domain PCs, HKCU policies are controlled by GPO and the
Trust Center settings are greyed out. Now also writes to HKLM
(requires admin rights) which overrides GPO settings.

Shows orange hint in settings when GPO lock is detected:
"Auf Domaenen-PCs: App einmalig als Admin starten!"

The app tries all 8 combinations: HKCU/HKLM x Policies/direct
x 16.0/15.0. Silently skips paths where permissions are denied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:48:09 +02:00
duffyduck df13ddf6b1 Release v0.0.0.22 2026-04-03 19:38:02 +02:00
duffyduck c08a625348 Write Outlook security keys to both Policies and normal user path
On Terminal Servers, normal users cannot write to HKCU\Software\
Policies. Now also writes to HKCU\Software\Microsoft\Office\...\
Security which is always writable and also read by Outlook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:37:26 +02:00
duffyduck 4484f19d14 Release v0.0.0.21 2026-04-03 19:32:20 +02:00
duffyduck ca17e5d433 Set Outlook security registry keys for all Office versions
Apply to both 16.0 (2016-2024/365) and 15.0 (2013) registry
paths. Costs nothing and ensures it works regardless of which
Office version is installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:31:08 +02:00
duffyduck d89e36b962 Widen main window, add all Outlook security registry keys, add hint
- Main window wider (830px) so all buttons fit without resizing
- Set ALL Outlook Object Model Guard registry values (not just 3)
- Clean removal: delete entire Security subkey when disabling
- Add hint in settings that Outlook restart is needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:29:54 +02:00
12 changed files with 397 additions and 149 deletions
+65
View File
@@ -0,0 +1,65 @@
# Changelog
Alle nennenswerten Aenderungen an Starface Outlook Sync werden hier dokumentiert.
Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/),
Versionsschema ist `x.x.x.x` (siehe `release.sh`).
## [Unreleased]
### Behoben
- **Dubletten auf beiden Seiten beim Synchronisieren.** Mehrere zusammenhaengende
Ursachen wurden beseitigt:
- Eine unvollstaendig geladene Starface-Kontaktliste (z.B. durch einen
Lade-Fehler oder Timeout) liess die Engine Kontakte faelschlich als
"geloescht" ansehen, ihr Mapping verwerfen und sie beim naechsten Lauf neu
anlegen. Der Kontakt-Abruf bricht jetzt mit Fehlermeldung ab (inkl.
Wiederholversuch), statt still mit einer Teil-Liste weiterzuarbeiten.
- Ist ein Starface-Kontakt nicht in der geladenen Liste (z.B. anderes
Adressbuch), wird das Mapping jetzt **behalten** statt verworfen und neu
angelegt.
- Das Wiederzuordnen bestehender Kontakte war zu streng: eine von Starface
umformatierte Telefonnummer konnte einen eindeutigen E-Mail- oder
Namens-Treffer ueberstimmen und so eine Neuanlage statt Verknuepfung
ausloesen. Ein gleicher E-Mail- oder voller Namens-Treffer reicht jetzt.
- **Synchronisation extrem langsam / schrieb bei jedem Lauf alle Kontakte neu.**
Die Aenderungserkennung verglich einen einzigen Hash gegen beide Seiten.
Outlook und Starface stellen denselben Kontakt aber unterschiedlich dar
(nicht uebertragene Felder, abweichendes Telefonformat), wodurch der Hash nie
uebereinstimmte und praktisch jeder Kontakt bei jedem Sync neu geschrieben
wurde. Jede Seite hat jetzt eine eigene Baseline (`LastOutlookHash` /
`LastStarfaceHash`); nur tatsaechlich geaenderte Kontakte werden geschrieben.
### Geaendert
- Starface-Kontaktdetails werden beim Laden parallel abgerufen (8 gleichzeitig)
statt einzeln nacheinander deutlich schneller bei grossen Adressbuechern.
- `UpdateContact` (Outlook) und `UpdateContactAsync` (Starface) geben jetzt den
frisch eingelesenen Stand zurueck, damit die Baseline nach dem Schreiben
korrekt gesetzt wird und der Sync sauber konvergiert.
### Migration
- Bestehende Mapping-Dateien werden beim ersten Sync automatisch uebernommen
(ein ruhiger Durchlauf ohne Massen-Update).
- **Bereits vorhandene Dubletten werden nicht automatisch entfernt** die Fixes
verhindern nur neue. Vorhandene Doppel-Kontakte einmalig manuell bereinigen.
## [0.0.0.23]
### Behoben
- Kritischer Loesch-Fehler: Kontakte wurden bei einer geaenderten Outlook-EntryID
nicht mehr massenhaft geloescht.
### Geaendert
- Outlook-Sicherheitsschluessel werden fuer Domaenenumgebungen zusaetzlich nach
HKLM geschrieben (unterdrueckt die Outlook-Sicherheitsabfrage).
## Aeltere Versionen
Aeltere Releases (v0.0.0.1 v0.0.0.22) sind ueber die Git-Historie und die
Git-Tags nachvollziehbar.
+26
View File
@@ -42,6 +42,32 @@ Windows-Anwendung zur bidirektionalen Synchronisation von Kontakten zwischen Mic
5. Starface-Adressbuch und Outlook-Kontaktordner waehlen 5. Starface-Adressbuch und Outlook-Kontaktordner waehlen
6. Speichern und "Jetzt synchronisieren" 6. Speichern und "Jetzt synchronisieren"
### Outlook-Sicherheitsabfrage unterdruecken
Beim Zugriff auf Outlook-Kontakte zeigt Outlook standardmaessig einen
Sicherheitsdialog ("Ein Programm versucht auf Ihre E-Mail-Adressinformationen
zuzugreifen"). Dieser kann in den Einstellungen der App deaktiviert werden:
1. In der App auf "Einstellungen" klicken
2. "Outlook-Sicherheitsabfrage automatisch erlauben" aktivieren
3. Speichern
**Auf Domaenen-PCs / Terminal Servern:**
Die Outlook-Sicherheitseinstellungen werden dort per Gruppenrichtlinie (GPO)
gesteuert und sind im Trust Center ausgegraut. In diesem Fall muss die App
**einmalig als Administrator** gestartet werden, damit die Registry-Keys
unter HKLM geschrieben werden koennen:
1. Rechtsklick auf die App -> "Als Administrator ausfuehren"
2. Einstellungen -> "Outlook-Sicherheitsabfrage automatisch erlauben" aktivieren
3. Speichern und App schliessen
4. Outlook neu starten
5. App kann danach wieder normal (ohne Admin) gestartet werden
Die Einstellung bleibt dauerhaft bestehen und gilt fuer alle Benutzer
auf dem Rechner.
### Deinstallation ### Deinstallation
Ueber Windows Einstellungen -> Apps oder die Systemsteuerung. Ueber Windows Einstellungen -> Apps oder die Systemsteuerung.
+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.20" #define MyAppVersion "0.0.0.24"
#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"
@@ -45,6 +45,15 @@ namespace StarfaceOutlookSync.Models
public string ProfileId { get; set; } = ""; public string ProfileId { get; set; } = "";
public string OutlookEntryId { get; set; } = ""; public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = ""; public string StarfaceId { get; set; } = "";
// Getrennte Baselines pro Seite. Outlook und Starface stellen denselben
// Kontakt unterschiedlich dar (Felder, Telefonformat), daher MUSS jede
// Seite gegen ihre eigene zuletzt-gesehene Repraesentation verglichen
// werden - sonst gilt jeder Kontakt bei jedem Sync als geaendert.
public string LastOutlookHash { get; set; } = "";
public string LastStarfaceHash { get; set; } = "";
// Alt-Feld (vor v0.0.0.24). Nur noch fuer Migration bestehender Mappings.
public string LastSyncHash { get; set; } = ""; public string LastSyncHash { get; set; } = "";
} }
+67 -26
View File
@@ -40,40 +40,81 @@ namespace StarfaceOutlookSync.Models
} }
public void ApplyOutlookSecuritySetting() public void ApplyOutlookSecuritySetting()
{
var versions = new[] { "16.0", "15.0" };
var securityValues = new (string name, int value)[]
{
("ObjectModelGuard", 2),
("PromptOOMAddressBookAccess", 2),
("PromptOOMAddressInformationAccess", 2),
("PromptOOMSend", 2),
("PromptOOMSaveAs", 2),
("PromptOOMFormulaAccess", 2),
("PromptOOMCustomAction", 2),
("PromptSimpleMAPISend", 2),
("PromptSimpleMAPINameResolve", 2),
("PromptSimpleMAPIOpenMessage", 2),
("AdminSecurityMode", 3),
};
// In alle moeglichen Pfade schreiben (HKCU + HKLM, Policies + direkt)
var roots = new[] { Registry.CurrentUser, Registry.LocalMachine };
var prefixes = new[]
{
@"Software\Policies\Microsoft\Office",
@"Software\Microsoft\Office"
};
foreach (var ver in versions)
{
foreach (var root in roots)
{
foreach (var prefix in prefixes)
{
var regPath = $@"{prefix}\{ver}\Outlook\Security";
try
{
if (AutoAcceptOutlookPrompt)
{
var key = root.CreateSubKey(regPath);
if (key != null)
{
foreach (var (name, value) in securityValues)
key.SetValue(name, value, RegistryValueKind.DWord);
key.Close();
}
}
else
{
try { root.DeleteSubKey(regPath, false); } catch { }
}
}
catch { } // Kein Fehler wenn Rechte fehlen - naechsten Pfad versuchen
}
}
}
}
/// <summary>
/// Prueft ob die Outlook-Sicherheitseinstellung per GPO blockiert wird.
/// </summary>
public static bool IsOutlookSecurityLockedByPolicy()
{ {
try try
{ {
var regPath = @"Software\Policies\Microsoft\Office\16.0\Outlook\Security"; // Wenn HKLM Policies gesetzt sind und wir dort nicht schreiben koennen
var key = Registry.LocalMachine.OpenSubKey(
if (AutoAcceptOutlookPrompt) @"Software\Policies\Microsoft\Office\16.0\Outlook\Security", false);
if (key != null)
{ {
// Alle Outlook Object Model Guard Prompts unterdruecken var val = key.GetValue("AdminSecurityMode");
var key = Registry.CurrentUser.CreateSubKey(regPath);
key.SetValue("ObjectModelGuard", 2, RegistryValueKind.DWord);
key.SetValue("PromptOOMAddressBookAccess", 2, RegistryValueKind.DWord);
key.SetValue("PromptOOMAddressInformationAccess", 2, RegistryValueKind.DWord);
key.SetValue("AdminSecurityMode", 3, RegistryValueKind.DWord);
key.Close(); key.Close();
} if (val != null) return true;
else
{
// Zurueck auf Standard (Werte loeschen)
try
{
var key = Registry.CurrentUser.OpenSubKey(regPath, true);
if (key != null)
{
try { key.DeleteValue("ObjectModelGuard"); } catch { }
try { key.DeleteValue("PromptOOMAddressBookAccess"); } catch { }
try { key.DeleteValue("PromptOOMAddressInformationAccess"); } catch { }
try { key.DeleteValue("AdminSecurityMode"); } catch { }
key.Close();
}
}
catch { }
} }
} }
catch { } catch { }
return false;
} }
} }
} }
@@ -350,7 +350,13 @@ namespace StarfaceOutlookSync.Services
} }
} }
public bool UpdateContact(string entryId, UnifiedContact contact) /// <summary>
/// Aktualisiert den Outlook-Kontakt und gibt den frisch eingelesenen
/// Stand zurueck (null bei Fehler). Der zurueckgegebene Kontakt liefert
/// den massgeblichen Hash NACH dem Schreiben - noetig damit die naechste
/// Synchronisation den Kontakt nicht erneut als geaendert erkennt.
/// </summary>
public UnifiedContact UpdateContact(string entryId, UnifiedContact contact)
{ {
try try
{ {
@@ -361,15 +367,17 @@ namespace StarfaceOutlookSync.Services
MapToOutlook(contact, ci); MapToOutlook(contact, ci);
ci.Save(); ci.Save();
var updated = MapFromOutlook(ci);
Marshal.ReleaseComObject(ci); Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(ns); Marshal.ReleaseComObject(ns);
return true; return updated;
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"Error updating contact: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"Error updating contact: {ex.Message}");
return false; return null;
} }
} }
@@ -194,7 +194,16 @@ namespace StarfaceOutlookSync.Services
query += $"&tags={book.TagId}"; query += $"&tags={book.TagId}";
var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}"); var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}");
if (!resp.IsSuccessStatusCode) break; if (!resp.IsSuccessStatusCode)
{
// WICHTIG: nicht still abbrechen. Eine unvollstaendige Liste
// laesst die Sync-Engine Kontakte faelschlich als geloescht
// ansehen -> Mappings werden verworfen -> Dubletten.
throw new Exception(
$"Starface-Kontaktliste konnte nicht vollstaendig geladen werden " +
$"(Seite {page}: HTTP {(int)resp.StatusCode}). Synchronisation abgebrochen, " +
$"um Dubletten zu vermeiden.");
}
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
JArray array; JArray array;
@@ -232,31 +241,40 @@ namespace StarfaceOutlookSync.Services
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste"); OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
// Die Listen-API gibt nur Summary zurueck. if (firstPage)
// Jeden Kontakt einzeln abrufen fuer alle Felder.
foreach (var item in array)
{ {
var id = item["id"]?.ToString(); var firstId = array[0]?["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue; if (!string.IsNullOrEmpty(firstId))
try
{ {
var detailResp = await _http.GetAsync($"{_baseUrl}/contacts/{id}"); var sample = await FetchDetailAsync(firstId);
if (detailResp.IsSuccessStatusCode) if (sample != null)
{ OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{sample.ToString(Formatting.Indented)}");
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 { } firstPage = false;
}
// Die Listen-API liefert nur eine Zusammenfassung; jeder Kontakt
// muss einzeln geladen werden. Das parallelisieren (begrenzt),
// sonst dauert es bei vielen Kontakten extrem lange.
var ids = array
.Select(it => it["id"]?.ToString())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
const int maxParallel = 8;
for (int i = 0; i < ids.Count; i += maxParallel)
{
var batch = ids.Skip(i).Take(maxParallel)
.Select(async id =>
{
var detail = await FetchDetailAsync(id);
// null = 404 (zwischenzeitlich geloescht) -> ueberspringen.
return detail == null ? null : MapFromStarface(detail);
})
.ToList();
var mapped = await Task.WhenAll(batch);
contacts.AddRange(mapped.Where(c => c != null));
} }
if (array.Count < pageSize) break; if (array.Count < pageSize) break;
@@ -266,6 +284,36 @@ namespace StarfaceOutlookSync.Services
return contacts; return contacts;
} }
/// <summary>
/// Laedt das Detail-JSON eines Kontakts mit kleiner Wiederholung.
/// Gibt null zurueck, wenn der Kontakt zwischen Listen- und Detail-Abruf
/// wirklich geloescht wurde (404 - harmlos, wird uebersprungen).
/// Wirft bei transienten Fehlern, damit der Aufrufer NICHT mit einer
/// unvollstaendigen Liste weiterarbeitet (sonst entstehen Dubletten).
/// </summary>
private async Task<JObject> FetchDetailAsync(string id)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
var resp = await _http.GetAsync($"{_baseUrl}/contacts/{id}");
if (resp.IsSuccessStatusCode)
return JObject.Parse(await resp.Content.ReadAsStringAsync());
// 404 = zwischenzeitlich geloescht; nicht erneut versuchen.
if (resp.StatusCode == HttpStatusCode.NotFound) return null;
}
catch { }
await Task.Delay(250 * (attempt + 1));
}
throw new Exception(
$"Starface-Kontakt {id} konnte nach mehreren Versuchen nicht geladen werden. " +
$"Synchronisation abgebrochen, um Dubletten zu vermeiden.");
}
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book) public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
{ {
var sfContact = MapToStarface(contact); var sfContact = MapToStarface(contact);
@@ -300,7 +348,13 @@ namespace StarfaceOutlookSync.Services
return MapFromStarface(created); return MapFromStarface(created);
} }
public async Task<bool> UpdateContactAsync(string contactId, UnifiedContact contact, StarfaceAddressBook book) /// <summary>
/// Aktualisiert den Starface-Kontakt und gibt den massgeblichen Stand
/// NACH dem Schreiben zurueck (null bei Fehler). Wird fuer die getrennte
/// 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)
{ {
var sfContact = MapToStarface(contact); var sfContact = MapToStarface(contact);
sfContact["id"] = contactId; sfContact["id"] = contactId;
@@ -323,9 +377,37 @@ namespace StarfaceOutlookSync.Services
{ {
var respBody = await resp.Content.ReadAsStringAsync(); var respBody = await resp.Content.ReadAsStringAsync();
OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}"); OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
} }
return resp.IsSuccessStatusCode; // Frischen Stand zurueckgeben. Manche Versionen liefern den Kontakt
// direkt in der PUT-Antwort, sonst per GET nachladen.
try
{
var respBody = await resp.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(respBody))
{
var obj = JObject.Parse(respBody);
if (obj["blocks"] != null)
return MapFromStarface(obj);
}
}
catch { }
return await GetContactAsync(contactId) ?? contact;
}
/// <summary>Laedt einen einzelnen Kontakt mit allen Feldern.</summary>
public async Task<UnifiedContact> GetContactAsync(string contactId)
{
try
{
var resp = await _http.GetAsync($"{_baseUrl}/contacts/{contactId}");
if (!resp.IsSuccessStatusCode) return null;
var obj = JObject.Parse(await resp.Content.ReadAsStringAsync());
return MapFromStarface(obj);
}
catch { return null; }
} }
public async Task<bool> DeleteContactAsync(string contactId) public async Task<bool> DeleteContactAsync(string contactId)
+89 -85
View File
@@ -37,72 +37,38 @@ namespace StarfaceOutlookSync.Services
private static bool IsMatch(UnifiedContact a, UnifiedContact b) private static bool IsMatch(UnifiedContact a, UnifiedContact b)
{ {
// Mindestens ein identifizierendes Feld muss vorhanden sein bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
bool hasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName); && (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
bool hasEmail = !string.IsNullOrEmpty(a.Email);
bool hasPhone = !string.IsNullOrEmpty(a.PhoneWork) || !string.IsNullOrEmpty(a.PhoneMobile);
if (!hasName && !hasEmail && !hasPhone) return false; // Starke Identifikatoren
// E-Mail: wenn auf beiden Seiten vorhanden, muss sie gleich sein
// Wenn nur auf einer Seite vorhanden -> kein Match
if (!FieldsCompatible(a.Email, b.Email)) return false;
// Name: wenn auf einer Seite vorhanden, muss er gleich sein
if (!FieldsCompatible(a.FirstName, b.FirstName)) return false;
if (!FieldsCompatible(a.LastName, b.LastName)) return false;
// Firma: wenn auf einer Seite vorhanden, muss sie gleich sein
// Leere Firma vs. gefuellte Firma = verschiedene Kontakte
if (!FieldsCompatible(a.Company, b.Company)) return false;
// Telefon/Fax: wenn auf einer Seite vorhanden, muss es gleich sein
if (!PhoneFieldsCompatible(a.PhoneWork, b.PhoneWork)) return false;
if (!PhoneFieldsCompatible(a.PhoneMobile, b.PhoneMobile)) return false;
if (!PhoneFieldsCompatible(a.PhoneHome, b.PhoneHome)) return false;
if (!PhoneFieldsCompatible(a.Fax, b.Fax)) return false;
// Mindestens ein starkes Match muss vorhanden sein
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase); && a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName bool nameMatch = hasName
&& a.FirstName.Equals(b.FirstName, StringComparison.OrdinalIgnoreCase) && (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase)
&& a.LastName.Equals(b.LastName, StringComparison.OrdinalIgnoreCase) && (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase);
&& (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName));
bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork) bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork)
&& NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork)) && NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork))
|| (!string.IsNullOrEmpty(a.PhoneMobile) && !string.IsNullOrEmpty(b.PhoneMobile)
&& NormalizePhone(a.PhoneMobile) == NormalizePhone(b.PhoneMobile))
|| (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax) || (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax)
&& NormalizePhone(a.Fax) == NormalizePhone(b.Fax)); && NormalizePhone(a.Fax) == NormalizePhone(b.Fax));
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company) bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase); && a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Email oder Name reicht. Telefon/Fax nur mit Firma zusammen. // Widerspruch: beide haben eine E-Mail, aber unterschiedlich -> verschiedene Personen.
return emailMatch || nameMatch || (phoneMatch && companyMatch) || (companyMatch && phoneMatch); bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
}
/// <summary> // Gleiche E-Mail ist der staerkste Identifikator und reicht allein.
/// Prueft ob zwei Felder kompatibel sind. if (emailMatch) return true;
/// Beide leer = kompatibel. Beide gleich = kompatibel.
/// Eins leer, eins gefuellt = NICHT kompatibel (verschiedene Kontakte).
/// </summary>
private static bool FieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true; // Gleicher voller Name reicht, solange keine widerspruechliche E-Mail vorliegt.
if (aEmpty != bEmpty) return false; // Einer leer, anderer nicht // (Telefon-Umformatierung durch Starface darf einen Namens-Treffer NICHT verhindern.)
return a.Equals(b, StringComparison.OrdinalIgnoreCase); if (nameMatch && !emailContradiction) return true;
}
private static bool PhoneFieldsCompatible(string a, string b) // Schwacher Pfad: Telefon/Fax nur zusammen mit gleicher Firma und ohne E-Mail-Widerspruch.
{ if (phoneMatch && companyMatch && !emailContradiction) return true;
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true; return false;
if (aEmpty != bEmpty) return false;
return NormalizePhone(a) == NormalizePhone(b);
} }
private static string NormalizePhone(string phone) private static string NormalizePhone(string phone)
@@ -183,7 +149,21 @@ namespace StarfaceOutlookSync.Services
if (oc == null && sc != null) if (oc == null && sc != null)
{ {
// In Outlook geloescht -> in Starface auch loeschen // Outlook-Kontakt nicht gefunden.
// Erst pruefen ob er vielleicht nur eine neue EntryID hat
var reMatch = FindMatch(sc, outlookContacts.Where(c =>
!processedOutlookIds.Contains(c.OutlookEntryId)).ToList());
if (reMatch != null)
{
// Kontakt existiert noch in Outlook, nur EntryID geaendert
Log($" EntryID geaendert, verknuepfe neu: {sc.DisplayName}");
mapping.OutlookEntryId = reMatch.OutlookEntryId;
processedOutlookIds.Add(reMatch.OutlookEntryId);
newMappings.Add(mapping);
continue;
}
// Wirklich geloescht -> in Starface auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface) if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{ {
if (await starface.DeleteContactAsync(mapping.StarfaceId)) if (await starface.DeleteContactAsync(mapping.StarfaceId))
@@ -194,7 +174,6 @@ namespace StarfaceOutlookSync.Services
} }
else else
{ {
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping); newMappings.Add(mapping);
} }
continue; continue;
@@ -202,37 +181,50 @@ namespace StarfaceOutlookSync.Services
if (oc != null && sc == null) if (oc != null && sc == null)
{ {
// In Starface geloescht -> in Outlook auch loeschen // Starface-Kontakt nicht in der geladenen Liste.
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook) // Da unvollstaendige Ladevorgaenge inzwischen abgebrochen
{ // werden (siehe StarfaceApiClient), liegt das hoechstens an
if (_outlookService.DeleteContact(mapping.OutlookEntryId)) // einem anderen Adressbuch. NICHT loeschen und NICHT neu
{ // anlegen - sonst entstehen Dubletten. Mapping behalten,
result.Updated++; // beim naechsten Sync wird es erneut abgeglichen.
Log($" Geloescht (SF->OL): {oc.DisplayName}"); Log($" Starface-Kontakt nicht in Liste (anderes Adressbuch?), behalte Mapping: {oc.DisplayName}");
} newMappings.Add(mapping);
}
else
{
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping);
}
continue; continue;
} }
if (oc != null && sc != null) if (oc != null && sc != null)
{ {
// Beide vorhanden -> auf Aenderungen pruefen // Beide vorhanden -> auf Aenderungen pruefen.
// WICHTIG: jede Seite gegen ihre EIGENE Baseline pruefen.
// Outlook und Starface stellen denselben Kontakt
// unterschiedlich dar, ein gemeinsamer Hash schlaegt nie an.
var olHash = oc.GetHash(); var olHash = oc.GetHash();
var sfHash = sc.GetHash(); var sfHash = sc.GetHash();
bool olChanged = olHash != mapping.LastSyncHash;
bool sfChanged = sfHash != mapping.LastSyncHash; // Migration alter Mappings (nur LastSyncHash vorhanden):
// aktuellen Stand als Baseline uebernehmen und als synchron
// annehmen, damit kein Massen-Update ausgeloest wird.
if (string.IsNullOrEmpty(mapping.LastOutlookHash) &&
string.IsNullOrEmpty(mapping.LastStarfaceHash))
{
mapping.LastOutlookHash = olHash;
mapping.LastStarfaceHash = sfHash;
mapping.LastSyncHash = "";
newMappings.Add(mapping);
continue;
}
bool olChanged = olHash != mapping.LastOutlookHash;
bool sfChanged = sfHash != mapping.LastStarfaceHash;
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)) if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{ {
// Outlook hat sich geaendert -> Starface updaten // Outlook hat sich geaendert -> Starface updaten
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook)) var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{ {
mapping.LastSyncHash = olHash; mapping.LastOutlookHash = olHash;
mapping.LastStarfaceHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}"); Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
} }
@@ -240,30 +232,36 @@ namespace StarfaceOutlookSync.Services
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)) else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{ {
// Starface hat sich geaendert -> Outlook updaten // Starface hat sich geaendert -> Outlook updaten
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc)) var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null)
{ {
mapping.LastSyncHash = sfHash; mapping.LastStarfaceHash = sfHash;
mapping.LastOutlookHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}"); Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
} }
} }
else if (olChanged && sfChanged) else if (olChanged && sfChanged)
{ {
// Beide geaendert -> Konflikt, neuere gewinnt (Outlook bevorzugt) // Beide geaendert -> Konflikt, Outlook bevorzugt
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook) if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{ {
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook)) var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{ {
mapping.LastSyncHash = olHash; mapping.LastOutlookHash = olHash;
mapping.LastStarfaceHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}"); Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
} }
} }
else else
{ {
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc)) var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null)
{ {
mapping.LastSyncHash = sfHash; mapping.LastStarfaceHash = sfHash;
mapping.LastOutlookHash = updated.GetHash();
result.Updated++; result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}"); Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
} }
@@ -301,14 +299,16 @@ namespace StarfaceOutlookSync.Services
if (match != null) if (match != null)
{ {
// Existiert schon -> verknuepfen und updaten // Existiert schon -> verknuepfen und updaten
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook)) var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{ {
newMappings.Add(new SyncMapping newMappings.Add(new SyncMapping
{ {
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId, OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId, StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash() LastOutlookHash = oc.GetHash(),
LastStarfaceHash = updated.GetHash()
}); });
processedStarfaceIds.Add(match.StarfaceId); processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match); unmappedStarface.Remove(match);
@@ -328,7 +328,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId, OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId, StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash() LastOutlookHash = oc.GetHash(),
LastStarfaceHash = created.GetHash()
}); });
result.Created++; result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}"); Log($" Erstellt (OL->SF): {oc.DisplayName}");
@@ -374,14 +375,16 @@ namespace StarfaceOutlookSync.Services
if (match != null) if (match != null)
{ {
// Existiert schon -> verknuepfen und updaten // Existiert schon -> verknuepfen und updaten
if (_outlookService.UpdateContact(match.OutlookEntryId, sc)) var updated = _outlookService.UpdateContact(match.OutlookEntryId, sc);
if (updated != null)
{ {
newMappings.Add(new SyncMapping newMappings.Add(new SyncMapping
{ {
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId, OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId, StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash() LastStarfaceHash = sc.GetHash(),
LastOutlookHash = updated.GetHash()
}); });
processedOutlookIds.Add(match.OutlookEntryId); processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match); unmappedOutlook.Remove(match);
@@ -400,7 +403,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId, OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId, StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash() LastStarfaceHash = sc.GetHash(),
LastOutlookHash = created.GetHash()
}); });
result.Created++; result.Created++;
Log($" Erstellt (SF->OL): {sc.DisplayName}"); Log($" Erstellt (SF->OL): {sc.DisplayName}");
@@ -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.20</Version> <Version>0.0.0.24</Version>
<AssemblyVersion>0.0.0.20</AssemblyVersion> <AssemblyVersion>0.0.0.24</AssemblyVersion>
<FileVersion>0.0.0.20</FileVersion> <FileVersion>0.0.0.24</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.20", Text = "Version 0.0.0.24",
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
+2 -2
View File
@@ -76,8 +76,8 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent() private void InitializeComponent()
{ {
Text = "Starface Kontakt-Sync"; Text = "Starface Kontakt-Sync";
Size = new Size(620, 450); Size = new Size(830, 450);
MinimumSize = new Size(500, 350); MinimumSize = new Size(830, 350);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9); Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon(); Icon = AppIcon.GetIcon();
+16 -3
View File
@@ -47,20 +47,33 @@ namespace StarfaceOutlookSync.UI
Checked = _settings.AutoAcceptOutlookPrompt Checked = _settings.AutoAcceptOutlookPrompt
}; };
var hintText = "Hinweis: Outlook muss nach Aenderung neu gestartet werden.";
if (UserSettings.IsOutlookSecurityLockedByPolicy())
hintText += "\nAuf Domaenen-PCs: App einmalig als Admin starten!";
var lblHint = new Label
{
Text = hintText,
Left = 38, Top = 102, Width = 310, Height = 36,
ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray,
Font = new Font("Segoe UI", 8)
};
_btnSave = new Button _btnSave = new Button
{ {
Text = "Speichern", Left = 95, Top = 160, Width = 85, Height = 28, Text = "Speichern", Left = 95, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.None DialogResult = DialogResult.None
}; };
_btnSave.Click += (s, e) => Save(); _btnSave.Click += (s, e) => Save();
_btnCancel = new Button _btnCancel = new Button
{ {
Text = "Abbrechen", Left = 189, Top = 160, Width = 85, Height = 28, Text = "Abbrechen", Left = 189, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel DialogResult = DialogResult.Cancel
}; };
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, _btnSave, _btnCancel }); Size = new Size(380, 260);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel });
AcceptButton = _btnSave; AcceptButton = _btnSave;
CancelButton = _btnCancel; CancelButton = _btnCancel;
} }