Compare commits

...

9 Commits

Author SHA1 Message Date
duffyduck 1987e25c37 Release v0.0.0.26 2026-06-08 12:04:57 +02:00
duffyduck b5ad59ff9d One-way sync modes now do a full replace of the target
Outlook->Starface macht das Starface-Adressbuch zur exakten Kopie von
Outlook: Kontakte, die nur in Starface existieren, werden geloescht.
Starface->Outlook entsprechend umgekehrt (Phase 4).

Sicherheit:
- Loeschphase laeuft nur bei vollstaendig geladener Liste (unvollstaendige
  Ladevorgaenge brechen schon vorher ab).
- Ist die Quelle komplett leer (z.B. falscher Ordner), wird die Loeschphase
  uebersprungen statt die Zielseite zu leeren.

UI: Profil-Editor zeigt jetzt unter der Sync-Richtung einen Warnhinweis, der
das jeweilige Verhalten erklaert. README/CHANGELOG aktualisiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:04:17 +02:00
duffyduck 1e9ff63833 Propagate deletions in bidirectional sync via baseline tombstone
Im Both-Modus wurde ein auf einer Seite geloeschter Kontakt bisher auf der
anderen Seite einfach wieder angelegt, statt die Loeschung zu spiegeln.

Jetzt wird anhand der gespeicherten Baseline (LastOutlookHash /
LastStarfaceHash) entschieden:
- Gegenseite seit letztem Sync unveraendert -> es war eine Loeschung ->
  auf der anderen Seite ebenfalls loeschen.
- Gegenseite wurde geaendert -> Bearbeitung gewinnt -> neu anlegen
  (kein Datenverlust).

In den Ein-Richtungs-Modi bleibt die Quelle fuehrend: eine Loeschung im
Ziel wird aus der Quelle wiederhergestellt (StarfaceToOutlook legt einen
in Outlook geloeschten Kontakt jetzt ebenfalls wieder an statt ein totes
Mapping zu behalten).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:59:12 +02:00
duffyduck 561ffff03e Release v0.0.0.25 2026-06-08 11:51:35 +02:00
duffyduck 53eed8eda3 Fix: recreate genuinely-deleted Starface contacts instead of keeping dead mapping
Der vorherige Fix war zu konservativ: bei einem in der geladenen Liste
fehlenden Starface-Kontakt wurde das Mapping immer behalten und nichts neu
angelegt - auch wenn der Kontakt in Starface wirklich geloescht war. In
Richtung Outlook->Starface wurden geloeschte Kontakte dadurch nie wieder
angelegt.

Jetzt wird der Kontakt per ID abgefragt:
- existiert noch (anderes Adressbuch) -> Mapping behalten, nichts anlegen
- 404 (wirklich geloescht) -> in Both/OutlookToStarface neu anlegen
  (Phase 2), in StarfaceToOutlook Loeschung nach Outlook spiegeln

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:24:30 +02:00
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
10 changed files with 504 additions and 115 deletions
+83
View File
@@ -0,0 +1,83 @@
# 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, wird er jetzt per
ID direkt abgefragt: existiert er noch (liegt also in einem **anderen
Adressbuch**), bleibt das Mapping erhalten und es wird nichts neu angelegt
(keine Dublette). Ist er **wirklich geloescht** (404), wird er je nach
Sync-Richtung in Starface neu angelegt bzw. die Loeschung nach Outlook
gespiegelt. (Vorher wurde das Mapping faelschlich behalten und der Kontakt
in Outlook->Starface gar nicht 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
- **Ein-Richtungs-Modi sind jetzt echtes "Ersetzen".** Outlook->Starface macht
das Starface-Adressbuch zu einer exakten Kopie von Outlook: Kontakte, die nur
in Starface existieren (kein Pendant in Outlook), werden geloescht.
Starface->Outlook entsprechend umgekehrt. Schutz: Ist die Quelle komplett
leer (z.B. falscher Ordner gewaehlt), wird die Loeschphase uebersprungen
statt die Zielseite zu leeren. Mass-Loeschungen finden nur bei vollstaendig
geladener Liste statt (unvollstaendige Ladevorgaenge brechen vorher ab).
- **Bidirektionale Loeschungen** werden jetzt erkannt. Wird ein Kontakt auf
einer Seite geloescht und ist die andere Seite seit dem letzten Sync
unveraendert (Abgleich ueber die gespeicherte Baseline), wird die Loeschung
auf die andere Seite gespiegelt statt den Kontakt wieder anzulegen. Wurde
die andere Seite zwischenzeitlich bearbeitet, gewinnt die Bearbeitung und der
Kontakt wird neu angelegt (kein Datenverlust). In den Ein-Richtungs-Modi
bleibt die jeweilige Quelle fuehrend (Loeschung im Ziel wird wiederhergestellt).
- 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.
+30 -1
View File
@@ -8,7 +8,10 @@ Windows-Anwendung zur bidirektionalen Synchronisation von Kontakten zwischen Mic
- **Profil-System** zum Verwalten mehrerer Sync-Konfigurationen (verschiedene Adressbuecher oder Anlagen)
- **Starface-Adressbuecher**: Zentrales Adressbuch, persoenliches Adressbuch und Tag-basierte Adressbuecher
- **Outlook-Kontaktordner**: Frei waehlbarer Kontaktordner als Sync-Ziel
- **Sync-Richtung** konfigurierbar: Outlook -> Starface, Starface -> Outlook oder bidirektional
- **Sync-Richtung** konfigurierbar:
- *Bidirektional*: Aenderungen werden in beide Richtungen abgeglichen (inkl. Loeschungen)
- *Outlook -> Starface*: Das Starface-Adressbuch wird zur exakten Kopie von Outlook (nur in Starface vorhandene Kontakte werden geloescht)
- *Starface -> Outlook*: Der Outlook-Ordner wird zur exakten Kopie von Starface (nur in Outlook vorhandene Kontakte werden geloescht)
- **Intelligentes Matching**: Kontakte werden anhand von E-Mail-Adresse oder Name abgeglichen
- **Aenderungserkennung**: Nur geaenderte Kontakte werden uebertragen (Hash-basiert)
- **Auto-Sync**: Optionaler automatischer Sync in konfigurierbarem Intervall
@@ -42,6 +45,32 @@ Windows-Anwendung zur bidirektionalen Synchronisation von Kontakten zwischen Mic
5. Starface-Adressbuch und Outlook-Kontaktordner waehlen
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
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)
#define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.23"
#define MyAppVersion "0.0.0.26"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"
@@ -45,6 +45,15 @@ namespace StarfaceOutlookSync.Models
public string ProfileId { get; set; } = "";
public string OutlookEntryId { 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; } = "";
}
@@ -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
{
@@ -361,15 +367,17 @@ namespace StarfaceOutlookSync.Services
MapToOutlook(contact, ci);
ci.Save();
var updated = MapFromOutlook(ci);
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(ns);
return true;
return updated;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error updating contact: {ex.Message}");
return false;
return null;
}
}
@@ -194,7 +194,16 @@ namespace StarfaceOutlookSync.Services
query += $"&tags={book.TagId}";
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();
JArray array;
@@ -232,31 +241,40 @@ namespace StarfaceOutlookSync.Services
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)
if (firstPage)
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
try
var firstId = array[0]?["id"]?.ToString();
if (!string.IsNullOrEmpty(firstId))
{
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));
}
var sample = await FetchDetailAsync(firstId);
if (sample != null)
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{sample.ToString(Formatting.Indented)}");
}
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;
@@ -266,6 +284,36 @@ namespace StarfaceOutlookSync.Services
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)
{
var sfContact = MapToStarface(contact);
@@ -300,7 +348,13 @@ namespace StarfaceOutlookSync.Services
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);
sfContact["id"] = contactId;
@@ -323,9 +377,37 @@ namespace StarfaceOutlookSync.Services
{
var respBody = await resp.Content.ReadAsStringAsync();
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)
+230 -80
View File
@@ -37,72 +37,38 @@ namespace StarfaceOutlookSync.Services
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 hasEmail = !string.IsNullOrEmpty(a.Email);
bool hasPhone = !string.IsNullOrEmpty(a.PhoneWork) || !string.IsNullOrEmpty(a.PhoneMobile);
bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
&& (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
if (!hasName && !hasEmail && !hasPhone) return false;
// 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
// Starke Identifikatoren
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& a.FirstName.Equals(b.FirstName, StringComparison.OrdinalIgnoreCase)
&& a.LastName.Equals(b.LastName, StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName));
&& (a.FirstName ?? "").Equals(b.FirstName ?? "", StringComparison.OrdinalIgnoreCase)
&& (a.LastName ?? "").Equals(b.LastName ?? "", StringComparison.OrdinalIgnoreCase);
bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(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)
&& NormalizePhone(a.Fax) == NormalizePhone(b.Fax));
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Email oder Name reicht. Telefon/Fax nur mit Firma zusammen.
return emailMatch || nameMatch || (phoneMatch && companyMatch) || (companyMatch && phoneMatch);
}
// Widerspruch: beide haben eine E-Mail, aber unterschiedlich -> verschiedene Personen.
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
/// <summary>
/// Prueft ob zwei Felder kompatibel sind.
/// 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);
// Gleiche E-Mail ist der staerkste Identifikator und reicht allein.
if (emailMatch) return true;
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false; // Einer leer, anderer nicht
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
// Gleicher voller Name reicht, solange keine widerspruechliche E-Mail vorliegt.
// (Telefon-Umformatierung durch Starface darf einen Namens-Treffer NICHT verhindern.)
if (nameMatch && !emailContradiction) return true;
private static bool PhoneFieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
// Schwacher Pfad: Telefon/Fax nur zusammen mit gleicher Firma und ohne E-Mail-Widerspruch.
if (phoneMatch && companyMatch && !emailContradiction) return true;
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false;
return NormalizePhone(a) == NormalizePhone(b);
return false;
}
private static string NormalizePhone(string phone)
@@ -183,56 +149,157 @@ namespace StarfaceOutlookSync.Services
if (oc == null && sc != null)
{
// In Outlook geloescht -> in Starface auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
// 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 in Outlook geloescht.
if (profile.SyncDirection == SyncDirection.OutlookToStarface)
{
// Outlook ist fuehrend -> Loeschung nach Starface spiegeln.
if (await starface.DeleteContactAsync(mapping.StarfaceId))
{
result.Updated++;
Log($" Geloescht (OL->SF): {sc.DisplayName}");
}
continue;
}
else
if (profile.SyncDirection == SyncDirection.Both)
{
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping);
// Bidirektional: anhand der Baseline pruefen, ob die
// Starface-Seite seit dem letzten Sync unveraendert ist.
bool sfUnchanged = !string.IsNullOrEmpty(mapping.LastStarfaceHash)
&& sc.GetHash() == mapping.LastStarfaceHash;
if (sfUnchanged)
{
// Unveraendert + in Outlook geloescht -> Loeschung gilt
// -> auch aus Starface entfernen.
if (await starface.DeleteContactAsync(mapping.StarfaceId))
{
result.Updated++;
Log($" Geloescht (OL->SF): {sc.DisplayName}");
}
continue;
}
// In Outlook geloescht, aber in Starface geaendert ->
// Bearbeitung gewinnt, in Outlook neu anlegen (Phase 3).
Log($" In Outlook geloescht, in Starface geaendert -> neu anlegen: {sc.DisplayName}");
processedStarfaceIds.Remove(sc.StarfaceId);
continue;
}
// StarfaceToOutlook: Starface ist alleinige Quelle -> in Outlook
// neu anlegen (Loeschung im Ziel zaehlt nicht).
Log($" Outlook-Kontakt geloescht, wird neu angelegt: {sc.DisplayName}");
processedStarfaceIds.Remove(sc.StarfaceId);
continue;
}
if (oc != null && sc == null)
{
// In Starface geloescht -> in Outlook auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
// Starface-Kontakt nicht in der geladenen Liste. Zwei Faelle
// unterscheiden, indem wir ihn per ID direkt abfragen:
// (a) per ID noch vorhanden -> liegt in einem ANDEREN
// Adressbuch -> Mapping behalten, NICHT neu anlegen
// (sonst Dublette).
// (b) per ID 404 -> in Starface WIRKLICH geloescht.
bool stillExists = !string.IsNullOrEmpty(mapping.StarfaceId)
&& await starface.GetContactAsync(mapping.StarfaceId) != null;
if (stillExists)
{
if (_outlookService.DeleteContact(mapping.OutlookEntryId))
Log($" Starface-Kontakt in anderem Adressbuch, behalte Mapping: {oc.DisplayName}");
newMappings.Add(mapping);
continue;
}
// Wirklich in Starface geloescht.
if (profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
// Starface ist fuehrend -> Loeschung nach Outlook spiegeln.
if (_outlookService.DeleteContact(oc.OutlookEntryId))
{
result.Updated++;
Log($" Geloescht (SF->OL): {oc.DisplayName}");
}
continue;
}
else
if (profile.SyncDirection == SyncDirection.Both)
{
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping);
// Bidirektional: anhand der Baseline entscheiden, ob der
// Outlook-Kontakt seit dem letzten Sync unveraendert ist.
bool olUnchanged = !string.IsNullOrEmpty(mapping.LastOutlookHash)
&& oc.GetHash() == mapping.LastOutlookHash;
if (olUnchanged)
{
// Unveraendert + in Starface geloescht -> Loeschung gilt
// -> aus Outlook entfernen.
if (_outlookService.DeleteContact(oc.OutlookEntryId))
{
result.Updated++;
Log($" Geloescht (SF->OL): {oc.DisplayName}");
}
continue;
}
// In Starface geloescht, aber in Outlook geaendert ->
// Bearbeitung gewinnt, in Starface neu anlegen.
Log($" In Starface geloescht, in Outlook geaendert -> neu anlegen: {oc.DisplayName}");
processedOutlookIds.Remove(oc.OutlookEntryId);
continue;
}
// OutlookToStarface: Outlook ist alleinige Quelle -> Kontakt
// in Starface neu anlegen (Loeschung im Ziel zaehlt nicht).
Log($" Starface-Kontakt geloescht, wird neu angelegt: {oc.DisplayName}");
processedOutlookIds.Remove(oc.OutlookEntryId);
continue;
}
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 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))
{
// 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++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
}
@@ -240,30 +307,36 @@ namespace StarfaceOutlookSync.Services
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{
// 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++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
}
}
else if (olChanged && sfChanged)
{
// Beide geaendert -> Konflikt, neuere gewinnt (Outlook bevorzugt)
// Beide geaendert -> Konflikt, Outlook bevorzugt
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++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
}
}
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++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
}
@@ -301,14 +374,16 @@ namespace StarfaceOutlookSync.Services
if (match != null)
{
// 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
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash()
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = updated.GetHash()
});
processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match);
@@ -328,7 +403,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = created.GetHash()
});
result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}");
@@ -374,14 +450,16 @@ namespace StarfaceOutlookSync.Services
if (match != null)
{
// 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
{
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = updated.GetHash()
});
processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match);
@@ -400,7 +478,8 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = created.GetHash()
});
result.Created++;
Log($" Erstellt (SF->OL): {sc.DisplayName}");
@@ -415,6 +494,77 @@ namespace StarfaceOutlookSync.Services
}
}
// ============================================
// Phase 4: Ersetzen-Modus - Zielseite an Quelle angleichen
// ============================================
// In den Ein-Richtungs-Modi soll die Zielseite eine exakte Kopie
// der Quelle werden. Kontakte, die nur auf der Zielseite existieren
// (kein Mapping, kein Treffer in der Quelle), werden geloescht.
// Sicher, weil unvollstaendige Ladevorgaenge vorher abbrechen.
if (profile.SyncDirection == SyncDirection.OutlookToStarface)
{
// Schutz gegen versehentliches Leerraeumen (z.B. falscher Ordner).
if (outlookContacts.Count == 0)
{
Log("Ersetzen-Modus uebersprungen: Outlook-Ordner ist leer (Schutz vor versehentlichem Leeren).");
}
else
{
var leftover = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
if (leftover.Count > 0)
Log($"Ersetzen-Modus: entferne {leftover.Count} Kontakt(e) aus Starface, die nicht in Outlook existieren");
foreach (var sc in leftover)
{
try
{
if (await starface.DeleteContactAsync(sc.StarfaceId))
{
result.Updated++;
Log($" Geloescht (nur in Starface): {sc.DisplayName}");
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"Loeschen SF {sc.DisplayName}: {ex.Message}");
}
}
}
}
else if (profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
if (starfaceContacts.Count == 0)
{
Log("Ersetzen-Modus uebersprungen: Starface-Adressbuch ist leer (Schutz vor versehentlichem Leeren).");
}
else
{
var leftover = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
if (leftover.Count > 0)
Log($"Ersetzen-Modus: entferne {leftover.Count} Kontakt(e) aus Outlook, die nicht in Starface existieren");
foreach (var oc in leftover)
{
try
{
if (_outlookService.DeleteContact(oc.OutlookEntryId))
{
result.Updated++;
Log($" Geloescht (nur in Outlook): {oc.DisplayName}");
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"Loeschen OL {oc.DisplayName}: {ex.Message}");
}
}
}
}
// Mappings speichern
_profileManager.SaveMappings(profile.Id, newMappings);
_profileManager.UpdateLastSync(profile.Id);
@@ -7,9 +7,9 @@
<AssemblyTitle>Starface Outlook Sync</AssemblyTitle>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product>
<Version>0.0.0.23</Version>
<AssemblyVersion>0.0.0.23</AssemblyVersion>
<FileVersion>0.0.0.23</FileVersion>
<Version>0.0.0.26</Version>
<AssemblyVersion>0.0.0.26</AssemblyVersion>
<FileVersion>0.0.0.26</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
+1 -1
View File
@@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label
{
Text = "Version 0.0.0.23",
Text = "Version 0.0.0.26",
Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray
@@ -22,6 +22,7 @@ namespace StarfaceOutlookSync.UI
private NumericUpDown _numAutoSync;
private Button _btnTest, _btnLoadBooks, _btnSave, _btnCancel;
private Label _lblTestResult;
private Label _lblDirectionHint;
private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>();
private List<string> _outlookFolderPaths = new List<string>();
@@ -101,7 +102,18 @@ namespace StarfaceOutlookSync.UI
_cmbDirection = new ComboBox { Left = 12, Top = y, Width = 250, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" });
_cmbDirection.SelectedIndex = 0;
panel.Controls.Add(_cmbDirection); y += 32;
panel.Controls.Add(_cmbDirection); y += 28;
_lblDirectionHint = new Label
{
Left = 12, Top = y, Width = 360, Height = 32,
ForeColor = Color.FromArgb(150, 80, 0),
Font = new Font("Segoe UI", 8.25f)
};
_cmbDirection.SelectedIndexChanged += (s, e) => UpdateDirectionHint();
panel.Controls.Add(_lblDirectionHint);
UpdateDirectionHint();
y += 36;
panel.Controls.Add(MakeLabel("Auto-Sync Intervall (Minuten, 0 = manuell):", 12, y)); y += 22;
_numAutoSync = new NumericUpDown { Left = 12, Top = y, Width = 80, Minimum = 0, Maximum = 1440, Value = 0 };
@@ -122,6 +134,22 @@ namespace StarfaceOutlookSync.UI
CancelButton = _btnCancel;
}
private void UpdateDirectionHint()
{
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.";
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.";
break;
default: // Bidirektional
_lblDirectionHint.Text = "Aenderungen werden in beide Richtungen abgeglichen.";
break;
}
}
private Label MakeLabel(string text, int x, int y)
{
return new Label { Text = text, Left = x, Top = y, AutoSize = true };