Compare commits

...

38 Commits

Author SHA1 Message Date
duffyduck 31aef01ccd Release v0.0.0.27 2026-06-08 12:41:47 +02:00
duffyduck 6c9721acc6 Support UNC paths for shared lock directory; create it if missing
Das gemeinsame Verzeichnis darf ein UNC-Pfad sein (\\server\freigabe\...),
kein Netzlaufwerksbuchstabe noetig. Statt nur Directory.Exists zu pruefen
(und sonst still ohne Sperre zu syncen) wird das Verzeichnis bei Bedarf
angelegt; nur bei echtem Zugriffsfehler wird ohne Sperre fortgefahren.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:40:31 +02:00
duffyduck 212ced4c81 Add cross-client sync lock via shared directory
Verhindert, dass mehrere Arbeitsplaetze gleichzeitig dasselbe Starface-
Adressbuch synchronisieren (Dubletten/Lost-Updates bei echter Ueberlappung).

- Neues optionales Setting "Gemeinsames Verzeichnis" (UserSettings.SharedDirectory)
  in der Einstellungen-Maske inkl. Ordner-Browser.
- SyncLock: atomare Lock-Datei (FileMode.CreateNew) im gemeinsamen Verzeichnis,
  waehrend des Syncs offen gehalten -> bei Absturz gibt das OS das Handle frei
  und ein anderer Client uebernimmt die verwaiste Datei (Stale-Erkennung 15 Min,
  Loeschen scheitert am offenen Handle eines lebenden Halters).
- MainForm wartet vor dem Sync bis zu 2 Min auf eine freie Sperre, sonst wird
  der Lauf uebersprungen. Ohne/bei nicht erreichbarem Verzeichnis laeuft der
  Sync ohne diese Sperre weiter (lokaler Interlocked-Schutz bleibt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:38:45 +02:00
duffyduck d3fa452504 Add field-level 3-way merge for bidirectional conflicts
Bisher wurde bei einem Konflikt (beide Seiten geaendert) der ganze Datensatz
ueberschrieben - eine gleichzeitige Aenderung an einem anderen Feld ging
verloren (z.B. A aendert Telefon in Outlook, B aendert Mail in Starface ->
eine Aenderung weg).

Jetzt:
- Mapping speichert je Seite einen Snapshot des letzten Sync-Stands
  (LastOutlook/LastStarface), zusaetzlich zu den Hashes.
- Bei beidseitiger Aenderung im Both-Modus wird feldweise gemergt
  (ContactMerger): unterschiedliche Felder bleiben beide erhalten, nur bei
  echtem Konflikt am selben Feld gewinnt Outlook.
- Echte Feld-Konflikte landen in SyncResult.Conflicts und werden im MainForm
  per Tray-Meldung angezeigt.
- Snapshots werden in allen Baseline-Punkten gesetzt (Phase 1-3) und fuer
  aeltere Mappings beim naechsten unveraenderten Sync nachgetragen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:35:10 +02:00
duffyduck bee17a7fc6 Make local sync re-entrancy guard atomic
Der Schutz gegen gleichzeitige Syncs (manuell vs. Auto-Sync-Timer) war ein
nicht-atomares pruefen-und-setzen auf einem volatile bool. Zwischen Pruefung
und Setzen konnten ein UI-Klick und der Timer-Thread beide durchrutschen und
zwei Syncs gleichzeitig starten.

Jetzt per Interlocked.CompareExchange atomar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:14:23 +02:00
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
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
duffyduck 3092150d3a Release v0.0.0.20 2026-04-03 19:23:38 +02:00
duffyduck 163dc17b49 Add option to suppress Outlook security prompt
New setting "Outlook-Sicherheitsabfrage automatisch erlauben"
sets registry keys under HKCU\Policies\Microsoft\Office\16.0\
Outlook\Security to auto-approve Object Model Guard prompts.

Applied at app startup and when saving settings. Disabling the
option removes the registry values (back to Outlook default).
Works with all Outlook versions (2016-2024, same registry path).
No admin rights needed (HKCU).

Outlook must be restarted after changing this setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:22:53 +02:00
duffyduck b7cc335184 Add tray About menu, sync-on-start, sync lock
- "Ueber" menu item in tray context menu opens About dialog
- New user setting "Beim Start automatisch synchronisieren"
  syncs all enabled profiles once at app startup
- Sync lock prevents concurrent sync runs (timer, manual,
  on-start cannot overlap - second request is skipped)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:18:42 +02:00
duffyduck a19a39b7d2 Release v0.0.0.19 2026-04-03 18:56:01 +02:00
duffyduck 6349424007 Add debug logging and tag to UpdateContactAsync
- Include tags in PUT request (Starface may require it)
- Log failed updates with status code and response body
- Also committed: SafeGet for Outlook COM property reading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:54:52 +02:00
duffyduck 7ffaddc77f Use reflection-based SafeGet for Outlook COM property reading
Dynamic COM with ?? operator can fail silently for properties
that return COM null vs .NET null. Use GetType().InvokeMember()
which reliably reads any Outlook property and catches COM errors
per field instead of crashing the whole contact read.

Fixes Fax and other fields that may not have been read correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:54:10 +02:00
duffyduck 9007e2a9b5 Include Fax, HomePhone in matching and compatibility checks
- Fax and HomePhone are now checked for compatibility (filled on
  one side, empty on other = different contacts)
- Fax number can serve as a strong match together with Company
- Prevents fax-only contacts from being missed or mismatched

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:52:41 +02:00
duffyduck 0eadb2274e Release v0.0.0.18 2026-04-03 18:37:00 +02:00
duffyduck a676e064c4 Stricter contact matching to prevent false duplicates
New matching logic checks ALL identifying fields for compatibility:
- Empty vs filled field = different contacts (not a match)
- Both empty = compatible (ignored for matching)
- Both filled = must be equal

Fields checked: Email, FirstName, LastName, Company, PhoneWork,
PhoneMobile. Requires at least one strong match (email, name,
or phone) plus no conflicting fields.

Example: Two "Max Mustermann" where one has Company="Firma A"
and the other has empty Company are now correctly identified as
different contacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:35:44 +02:00
duffyduck 39ec176b16 Release v0.0.0.17 2026-04-03 18:31:03 +02:00
duffyduck 39be854a4a Propagate contact deletions during sync
When a mapped contact is deleted on one side, delete it on the
other side too (respecting sync direction setting):
- Deleted in Outlook -> delete in Starface (if direction allows)
- Deleted in Starface -> delete in Outlook (if direction allows)
- Deleted on both sides -> just remove the mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:29:56 +02:00
duffyduck 724beba34a Release v0.0.0.16 2026-04-03 18:24:35 +02:00
duffyduck 65d3e911d0 Auto-reset mappings when address book is changed in profile editor
When editing a profile and switching to a different Starface
address book, the old contact mappings are invalid (different
Starface IDs). Now automatically clears mappings and shows a
notification. Custom address books are already listed as tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:23:47 +02:00
duffyduck ab9c16c69a Fix contact creation: assign tag (address book) to new contacts
Starface requires every contact to be assigned to a tag.
- Load tag IDs when fetching address books (folder/all for central,
  folder/private for personal)
- Include tags array in POST /contacts body
- Debug log all discovered tags for troubleshooting

Important: Existing profiles need to reload address books (edit
profile -> load address books -> save) to pick up the tag IDs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:21:58 +02:00
duffyduck 3e22a40e17 Release v0.0.0.15 2026-04-03 18:18:08 +02:00
duffyduck 36aca2c04d Add custom app icon: contact silhouette with sync arrows
Generated programmatically, no external ICO file needed.
Blue rounded square with white person silhouette and green/yellow
sync arrows. Used for main window title bar and system tray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:17:26 +02:00
duffyduck 4f56f28ccb Fix multiple tray icons: create icon once, only update menu
SetupTrayIcon was called on every RefreshProfileList, creating
a new NotifyIcon each time. Split into SetupTrayIcon (once) and
UpdateTrayMenu (on refresh).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:15:44 +02:00
16 changed files with 1430 additions and 169 deletions
+105
View File
@@ -0,0 +1,105 @@
# 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.
### Hinzugefuegt
- **Clientuebergreifende Sync-Sperre (Mehrplatz).** In den Einstellungen laesst
sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein
Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere
Clients warten, bis sie frei ist (bis 2 Min, sonst wird der Lauf uebersprungen).
Stuerzt ein Client ab, uebernimmt ein anderer die verwaiste Sperre. Ist kein
Verzeichnis konfiguriert oder nicht erreichbar, wird ohne diese Sperre
weitergearbeitet (der lokale Schutz auf dem PC bleibt).
- **Feldweises 3-Wege-Merge bei Konflikten (bidirektional).** Wenn derselbe
Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt
Aenderungen an *unterschiedlichen* Feldern beide erhalten (z.B. einer aendert
die Telefonnummer in Outlook, ein anderer die E-Mail in Starface). Nur wenn
DASSELBE Feld auf beiden Seiten unterschiedlich geaendert wurde, greift die
Vorrang-Regel (Outlook gewinnt). Dafuer wird im Mapping zusaetzlich ein
Snapshot des letzten Sync-Stands je Seite gespeichert. Solche echten
Feld-Konflikte werden dem Benutzer per Tray-Meldung angezeigt.
### Geaendert
- **Doppelte Syncs verhindert (lokal).** Der Schutz gegen gleichzeitig laufende
Syncs (manuell + Auto-Sync-Timer) ist jetzt atomar (`Interlocked`) statt eines
nicht-atomaren `volatile bool`, bei dem beide in einem Zeitfenster
durchrutschen konnten.
- **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) - **Profil-System** zum Verwalten mehrerer Sync-Konfigurationen (verschiedene Adressbuecher oder Anlagen)
- **Starface-Adressbuecher**: Zentrales Adressbuch, persoenliches Adressbuch und Tag-basierte Adressbuecher - **Starface-Adressbuecher**: Zentrales Adressbuch, persoenliches Adressbuch und Tag-basierte Adressbuecher
- **Outlook-Kontaktordner**: Frei waehlbarer Kontaktordner als Sync-Ziel - **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 - **Intelligentes Matching**: Kontakte werden anhand von E-Mail-Adresse oder Name abgeglichen
- **Aenderungserkennung**: Nur geaenderte Kontakte werden uebertragen (Hash-basiert) - **Aenderungserkennung**: Nur geaenderte Kontakte werden uebertragen (Hash-basiert)
- **Auto-Sync**: Optionaler automatischer Sync in konfigurierbarem Intervall - **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 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.14" #define MyAppVersion "0.0.0.27"
#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,22 @@ 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; } = "";
// Snapshot des zuletzt synchronisierten Stands je Seite. Ermoeglicht ein
// feldweises 3-Wege-Merge bei Konflikten (welche Seite hat welches Feld
// geaendert), statt den ganzen Datensatz zu ueberschreiben. Null bei
// Alt-Mappings -> dann Fallback auf ganz-ueberschreiben.
public UnifiedContact LastOutlook { get; set; }
public UnifiedContact LastStarface { 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; } = "";
} }
@@ -56,5 +72,23 @@ namespace StarfaceOutlookSync.Models
public int Updated { get; set; } public int Updated { get; set; }
public int Errors { get; set; } public int Errors { get; set; }
public System.Collections.Generic.List<string> ErrorMessages { get; set; } = new System.Collections.Generic.List<string>(); public System.Collections.Generic.List<string> ErrorMessages { get; set; } = new System.Collections.Generic.List<string>();
// Echte Feld-Konflikte (dasselbe Feld auf beiden Seiten geaendert), die
// ueber die Vorrang-Regel aufgeloest wurden. Fuer Benutzer-Hinweise.
public System.Collections.Generic.List<FieldConflict> Conflicts { get; set; } = new System.Collections.Generic.List<FieldConflict>();
}
public class FieldConflict
{
public string StarfaceId { get; set; } = "";
public string ContactName { get; set; } = "";
public string Field { get; set; } = "";
public string OutlookValue { get; set; } = "";
public string StarfaceValue { get; set; } = "";
public string Winner { get; set; } = ""; // "Outlook" oder "Starface"
public override string ToString() =>
$"{ContactName}: Feld '{Field}' auf beiden Seiten geaendert " +
$"(Outlook: '{OutlookValue}' / Starface: '{StarfaceValue}') -> {Winner} uebernommen";
} }
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using Microsoft.Win32;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models namespace StarfaceOutlookSync.Models
@@ -7,6 +8,13 @@ namespace StarfaceOutlookSync.Models
public class UserSettings public class UserSettings
{ {
public bool StartMinimized { get; set; } = false; public bool StartMinimized { get; set; } = false;
public bool SyncOnStart { get; set; } = false;
public bool AutoAcceptOutlookPrompt { get; set; } = false;
// Gemeinsames Verzeichnis (Netzlaufwerk/UNC) fuer die clientuebergreifende
// Sync-Sperre. Leer = keine Sperre (nur lokaler Schutz). Verhindert, dass
// mehrere Arbeitsplaetze gleichzeitig dasselbe Adressbuch synchronisieren.
public string SharedDirectory { get; set; } = "";
private static readonly string SettingsFile = Path.Combine( private static readonly string SettingsFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -32,6 +40,86 @@ namespace StarfaceOutlookSync.Models
File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(this, Formatting.Indented)); File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(this, Formatting.Indented));
} }
catch { } catch { }
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
{
// Wenn HKLM Policies gesetzt sind und wir dort nicht schreiben koennen
var key = Registry.LocalMachine.OpenSubKey(
@"Software\Policies\Microsoft\Office\16.0\Outlook\Security", false);
if (key != null)
{
var val = key.GetValue("AdminSecurityMode");
key.Close();
if (val != null) return true;
}
}
catch { }
return false;
} }
} }
} }
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Feldweises 3-Wege-Merge zweier Kontaktstaende gegen ihre Baseline.
/// Erlaubt es, dass zwei Stellen unterschiedliche Felder desselben Kontakts
/// aendern, ohne dass eine Aenderung verloren geht. Nur wenn DASSELBE Feld
/// auf beiden Seiten geaendert wurde, greift die Vorrang-Regel.
/// </summary>
public static class ContactMerger
{
private class FieldDef
{
public string Label;
public Func<UnifiedContact, string> Get;
public Action<UnifiedContact, string> Set;
public bool IsPhone;
}
private static readonly FieldDef[] Fields = new[]
{
new FieldDef { Label = "Vorname", Get = c => c.FirstName, Set = (c, v) => c.FirstName = v },
new FieldDef { Label = "Nachname", Get = c => c.LastName, Set = (c, v) => c.LastName = v },
new FieldDef { Label = "Firma", Get = c => c.Company, Set = (c, v) => c.Company = v },
new FieldDef { Label = "Position", Get = c => c.JobTitle, Set = (c, v) => c.JobTitle = v },
new FieldDef { Label = "E-Mail", Get = c => c.Email, Set = (c, v) => c.Email = v },
new FieldDef { Label = "E-Mail 2", Get = c => c.EmailSecondary, Set = (c, v) => c.EmailSecondary = v },
new FieldDef { Label = "Telefon", Get = c => c.PhoneWork, Set = (c, v) => c.PhoneWork = v, IsPhone = true },
new FieldDef { Label = "Mobil", Get = c => c.PhoneMobile, Set = (c, v) => c.PhoneMobile = v, IsPhone = true },
new FieldDef { Label = "Telefon privat", Get = c => c.PhoneHome, Set = (c, v) => c.PhoneHome = v, IsPhone = true },
new FieldDef { Label = "Fax", Get = c => c.Fax, Set = (c, v) => c.Fax = v, IsPhone = true },
new FieldDef { Label = "Strasse", Get = c => c.Street, Set = (c, v) => c.Street = v },
new FieldDef { Label = "Ort", Get = c => c.City, Set = (c, v) => c.City = v },
new FieldDef { Label = "PLZ", Get = c => c.PostalCode, Set = (c, v) => c.PostalCode = v },
new FieldDef { Label = "Bundesland", Get = c => c.State, Set = (c, v) => c.State = v },
new FieldDef { Label = "Land", Get = c => c.Country, Set = (c, v) => c.Country = v },
new FieldDef { Label = "Webseite", Get = c => c.Website, Set = (c, v) => c.Website = v },
new FieldDef { Label = "Notizen", Get = c => c.Notes, Set = (c, v) => c.Notes = v },
new FieldDef { Label = "Anrede", Get = c => c.Salutation, Set = (c, v) => c.Salutation = v },
new FieldDef { Label = "Titel", Get = c => c.Title, Set = (c, v) => c.Title = v },
new FieldDef { Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v },
};
/// <summary>
/// Fuehrt den Outlook- und Starface-Stand gegen ihre jeweilige Baseline
/// zusammen. outlookWins entscheidet bei echten Feld-Konflikten.
/// </summary>
public static (UnifiedContact merged, List<FieldConflict> conflicts) Merge(
UnifiedContact baseOutlook, UnifiedContact baseStarface,
UnifiedContact outlook, UnifiedContact starface,
bool outlookWins)
{
var merged = new UnifiedContact
{
OutlookEntryId = outlook.OutlookEntryId,
StarfaceId = starface.StarfaceId
};
var conflicts = new List<FieldConflict>();
foreach (var f in Fields)
{
string olv = f.Get(outlook) ?? "";
string sfv = f.Get(starface) ?? "";
string bol = f.Get(baseOutlook) ?? "";
string bsf = f.Get(baseStarface) ?? "";
bool olChanged = !Equal(olv, bol, f.IsPhone);
bool sfChanged = !Equal(sfv, bsf, f.IsPhone);
string chosen;
if (olChanged && !sfChanged)
{
chosen = olv;
}
else if (sfChanged && !olChanged)
{
chosen = sfv;
}
else if (olChanged && sfChanged)
{
if (Equal(olv, sfv, f.IsPhone))
{
// Beide auf denselben Wert geaendert -> kein echter Konflikt.
chosen = olv;
}
else
{
chosen = outlookWins ? olv : sfv;
conflicts.Add(new FieldConflict
{
StarfaceId = starface.StarfaceId,
ContactName = outlook.DisplayName,
Field = f.Label,
OutlookValue = olv,
StarfaceValue = sfv,
Winner = outlookWins ? "Outlook" : "Starface"
});
}
}
else
{
// Keine Seite hat dieses Feld geaendert -> Outlook-Wert behalten.
chosen = olv;
}
f.Set(merged, chosen);
}
return (merged, conflicts);
}
private static bool Equal(string a, string b, bool isPhone)
{
if (isPhone)
return NormalizePhone(a) == NormalizePhone(b);
return string.Equals(a ?? "", b ?? "", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
}
}
@@ -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;
} }
} }
@@ -394,29 +402,40 @@ namespace StarfaceOutlookSync.Services
} }
} }
private static string SafeGet(dynamic ci, string property)
{
try
{
object val = ci.GetType().InvokeMember(property,
System.Reflection.BindingFlags.GetProperty, null, ci, null);
return val?.ToString() ?? "";
}
catch { return ""; }
}
private UnifiedContact MapFromOutlook(dynamic ci) private UnifiedContact MapFromOutlook(dynamic ci)
{ {
return new UnifiedContact return new UnifiedContact
{ {
OutlookEntryId = (string)(ci.EntryID ?? ""), OutlookEntryId = SafeGet(ci, "EntryID"),
FirstName = (string)(ci.FirstName ?? ""), FirstName = SafeGet(ci, "FirstName"),
LastName = (string)(ci.LastName ?? ""), LastName = SafeGet(ci, "LastName"),
Company = (string)(ci.CompanyName ?? ""), Company = SafeGet(ci, "CompanyName"),
JobTitle = (string)(ci.JobTitle ?? ""), JobTitle = SafeGet(ci, "JobTitle"),
Email = (string)(ci.Email1Address ?? ""), Email = SafeGet(ci, "Email1Address"),
EmailSecondary = (string)(ci.Email2Address ?? ""), EmailSecondary = SafeGet(ci, "Email2Address"),
PhoneWork = (string)(ci.BusinessTelephoneNumber ?? ""), PhoneWork = SafeGet(ci, "BusinessTelephoneNumber"),
PhoneMobile = (string)(ci.MobileTelephoneNumber ?? ""), PhoneMobile = SafeGet(ci, "MobileTelephoneNumber"),
PhoneHome = (string)(ci.HomeTelephoneNumber ?? ""), PhoneHome = SafeGet(ci, "HomeTelephoneNumber"),
Fax = (string)(ci.BusinessFaxNumber ?? ""), Fax = SafeGet(ci, "BusinessFaxNumber"),
Street = (string)(ci.BusinessAddressStreet ?? ""), Street = SafeGet(ci, "BusinessAddressStreet"),
City = (string)(ci.BusinessAddressCity ?? ""), City = SafeGet(ci, "BusinessAddressCity"),
PostalCode = (string)(ci.BusinessAddressPostalCode ?? ""), PostalCode = SafeGet(ci, "BusinessAddressPostalCode"),
State = (string)(ci.BusinessAddressState ?? ""), State = SafeGet(ci, "BusinessAddressState"),
Country = (string)(ci.BusinessAddressCountry ?? ""), Country = SafeGet(ci, "BusinessAddressCountry"),
Website = (string)(ci.WebPage ?? ""), Website = SafeGet(ci, "WebPage"),
Notes = (string)(ci.Body ?? ""), Notes = SafeGet(ci, "Body"),
Salutation = (string)(ci.Title ?? ""), Salutation = SafeGet(ci, "Title"),
Birthday = GetBirthdayString(ci) Birthday = GetBirthdayString(ci)
}; };
} }
@@ -425,8 +444,9 @@ namespace StarfaceOutlookSync.Services
{ {
try try
{ {
DateTime bday = ci.Birthday; object val = ci.GetType().InvokeMember("Birthday",
if (bday.Year > 1900 && bday != DateTime.MinValue) System.Reflection.BindingFlags.GetProperty, null, ci, null);
if (val is DateTime bday && bday.Year > 1900 && bday != DateTime.MinValue)
return bday.ToString("yyyy-MM-dd"); return bday.ToString("yyyy-MM-dd");
} }
catch { } catch { }
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Security.Cryptography; using System.Security.Cryptography;
@@ -115,42 +116,62 @@ namespace StarfaceOutlookSync.Services
{ {
var books = new List<StarfaceAddressBook>(); var books = new List<StarfaceAddressBook>();
books.Add(new StarfaceAddressBook // Alle Tags laden - die Starface nutzt Tags als Adressbuch-Zuordnung
{ var allTags = new JArray();
Type = "central",
Name = "Zentrales Adressbuch"
});
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
Name = "Persoenliches Adressbuch"
});
}
// Tags als virtuelle Adressbuecher
try try
{ {
var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags"); var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags");
if (resp.IsSuccessStatusCode) if (resp.IsSuccessStatusCode)
{ {
var tags = JArray.Parse(await resp.Content.ReadAsStringAsync()); allTags = JArray.Parse(await resp.Content.ReadAsStringAsync());
foreach (var tag in tags) OnDebug?.Invoke($"Gefundene Tags: {allTags.Count}");
foreach (var t in allTags)
OnDebug?.Invoke($" Tag: {t["name"]} (id: {t["id"]}, alias: {t["alias"]}, owner: {t["owner"]})");
}
}
catch { }
// Zentrales Adressbuch (folder/all)
var allTag = allTags.FirstOrDefault(t => t["name"]?.ToString() == "folder/all"
|| t["alias"]?.ToString()?.Contains("folder.all") == true);
books.Add(new StarfaceAddressBook
{ {
Type = "central",
TagId = allTag?["id"]?.ToString() ?? "",
Name = "Zentrales Adressbuch"
});
// Persoenliches Adressbuch (folder/private mit owner = userId)
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
var privateTag = allTags.FirstOrDefault(t =>
(t["name"]?.ToString() == "folder/private" || t["alias"]?.ToString()?.Contains("folder.private") == true)
&& t["owner"]?.ToString() == userId);
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
TagId = privateTag?["id"]?.ToString() ?? "",
Name = "Persoenliches Adressbuch"
});
}
// Alle weiteren Tags als Adressbuecher anbieten
foreach (var tag in allTags)
{
var tagName = tag["name"]?.ToString() ?? "";
// folder/all und folder/private bereits oben erfasst
if (tagName == "folder/all" || tagName == "folder/private") continue;
books.Add(new StarfaceAddressBook books.Add(new StarfaceAddressBook
{ {
Type = "tag", Type = "tag",
TagId = tag["id"]?.ToString() ?? "", TagId = tag["id"]?.ToString() ?? "",
Name = $"Tag: {tag["name"]}" Name = tagName
}); });
} }
}
}
catch { }
return books; return books;
} }
@@ -173,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;
@@ -211,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.
// Jeden Kontakt einzeln abrufen fuer alle Felder.
foreach (var item in array)
{
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) if (firstPage)
{ {
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{detailObj.ToString(Formatting.Indented)}"); var firstId = array[0]?["id"]?.ToString();
if (!string.IsNullOrEmpty(firstId))
{
var sample = await FetchDetailAsync(firstId);
if (sample != null)
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{sample.ToString(Formatting.Indented)}");
}
firstPage = false; firstPage = false;
} }
contacts.Add(MapFromStarface(detailObj)); // Die Listen-API liefert nur eine Zusammenfassung; jeder Kontakt
} // muss einzeln geladen werden. Das parallelisieren (begrenzt),
} // sonst dauert es bei vielen Kontakten extrem lange.
catch { } 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;
@@ -245,9 +284,49 @@ 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);
// Tag zuweisen - die Starface verlangt dass jeder Kontakt einem Tag zugeordnet ist
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray
{
new JObject { ["id"] = book.TagId }
};
}
var query = ""; var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId)) if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}"; query = $"?userId={book.UserId}";
@@ -269,17 +348,66 @@ 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;
// Tag beibehalten
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray { new JObject { ["id"] = book.TagId } };
}
var query = ""; var query = "";
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 content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content); var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content);
return resp.IsSuccessStatusCode;
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync();
OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
}
// 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)
+345 -64
View File
@@ -15,63 +15,76 @@ namespace StarfaceOutlookSync.Services
private void Log(string message) => OnProgress?.Invoke(message); private void Log(string message) => OnProgress?.Invoke(message);
/// <summary>
/// Setzt die Baseline eines Mappings auf den uebergebenen Stand beider
/// Seiten (Snapshot + Hash). Der Snapshot wird fuer das Feld-Merge bei
/// kuenftigen Konflikten gebraucht.
/// </summary>
private static void SetBaseline(SyncMapping m, UnifiedContact outlook, UnifiedContact starface)
{
m.LastOutlook = outlook;
m.LastStarface = starface;
m.LastOutlookHash = outlook?.GetHash() ?? "";
m.LastStarfaceHash = starface?.GetHash() ?? "";
m.LastSyncHash = "";
}
/// <summary> /// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste. /// Findet einen passenden Kontakt in der Kandidatenliste.
/// Matching-Reihenfolge: E-Mail, dann Vorname+Nachname+Firma, dann Vorname+Nachname. /// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
/// auf der anderen auch gefuellt (und gleich) sein.
/// Ein leeres Feld auf einer Seite und ein gefuelltes auf der anderen
/// bedeutet: verschiedene Kontakte.
/// </summary> /// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates) private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{ {
if (candidates == null || candidates.Count == 0) return null; if (candidates == null || candidates.Count == 0) return null;
// 1. Exakte E-Mail foreach (var c in candidates)
if (!string.IsNullOrEmpty(contact.Email))
{ {
var byEmail = candidates.FirstOrDefault(c => if (IsMatch(contact, c))
!string.IsNullOrEmpty(c.Email) && return c;
c.Email.Equals(contact.Email, StringComparison.OrdinalIgnoreCase));
if (byEmail != null) return byEmail;
}
// 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))
{
var byName = candidates.FirstOrDefault(c =>
c.FirstName.Equals(contact.FirstName, StringComparison.OrdinalIgnoreCase) &&
c.LastName.Equals(contact.LastName, StringComparison.OrdinalIgnoreCase) &&
(!string.IsNullOrEmpty(c.FirstName) || !string.IsNullOrEmpty(c.LastName)));
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 bool IsMatch(UnifiedContact a, UnifiedContact b)
{
bool hasName = (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName))
&& (!string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName));
// 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);
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);
// Widerspruch: beide haben eine E-Mail, aber unterschiedlich -> verschiedene Personen.
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
// Gleiche E-Mail ist der staerkste Identifikator und reicht allein.
if (emailMatch) return true;
// 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;
// Schwacher Pfad: Telefon/Fax nur zusammen mit gleicher Firma und ohne E-Mail-Widerspruch.
if (phoneMatch && companyMatch && !emailContradiction) return true;
return false;
}
private static string NormalizePhone(string phone) private static string NormalizePhone(string phone)
{ {
if (string.IsNullOrEmpty(phone)) return ""; if (string.IsNullOrEmpty(phone)) return "";
@@ -144,23 +157,170 @@ namespace StarfaceOutlookSync.Services
if (oc == null && sc == null) if (oc == null && sc == null)
{ {
// Beide Seiten geloescht -> Mapping entfernen // Beide Seiten geloescht -> Mapping entfernen
Log($" Mapping verwaist (beide geloescht), entferne");
continue;
}
if (oc == null && sc != null)
{
// 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;
}
if (profile.SyncDirection == SyncDirection.Both)
{
// 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)
{
// 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)
{
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;
}
if (profile.SyncDirection == SyncDirection.Both)
{
// 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; 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))
{
SetBaseline(mapping, oc, sc);
newMappings.Add(mapping);
continue;
}
bool olChanged = olHash != mapping.LastOutlookHash;
bool sfChanged = sfHash != mapping.LastStarfaceHash;
if (!olChanged && !sfChanged)
{
// Unveraendert. Snapshots aelterer Mappings (nur Hash)
// nachtragen, damit kuenftige Konflikte gemergt werden koennen.
if (mapping.LastOutlook == null || mapping.LastStarface == null)
SetBaseline(mapping, oc, sc);
newMappings.Add(mapping);
continue;
}
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; SetBaseline(mapping, oc, updated);
result.Updated++; result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}"); Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
} }
@@ -168,36 +328,72 @@ 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; SetBaseline(mapping, updated, sc);
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 Seiten geaendert.
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook) if (profile.SyncDirection == SyncDirection.Both
&& mapping.LastOutlook != null && mapping.LastStarface != null)
{ {
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook)) // Feldweises 3-Wege-Merge: unterschiedliche Felder
// bleiben beide erhalten; nur bei gleichem Feld auf
// beiden Seiten gewinnt Outlook.
var (merged, conflicts) = ContactMerger.Merge(
mapping.LastOutlook, mapping.LastStarface, oc, sc, outlookWins: true);
var updatedSf = await starface.UpdateContactAsync(mapping.StarfaceId, merged, profile.StarfaceAddressBook);
var updatedOl = _outlookService.UpdateContact(mapping.OutlookEntryId, merged);
if (updatedSf != null || updatedOl != null)
{ {
mapping.LastSyncHash = olHash; if (updatedOl != null)
{
mapping.LastOutlook = updatedOl;
mapping.LastOutlookHash = updatedOl.GetHash();
}
if (updatedSf != null)
{
mapping.LastStarface = updatedSf;
mapping.LastStarfaceHash = updatedSf.GetHash();
}
mapping.LastSyncHash = "";
result.Updated++;
foreach (var cf in conflicts) result.Conflicts.Add(cf);
Log(conflicts.Count > 0
? $" Beidseitig geaendert, zusammengefuehrt ({conflicts.Count} Feld-Konflikt(e), Outlook gewinnt): {oc.DisplayName}"
: $" Beidseitig geaendert, zusammengefuehrt: {oc.DisplayName}");
}
}
else if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{
// Fallback ohne Snapshot bzw. OutlookToStarface:
// Outlook gewinnt komplett.
var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
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; SetBaseline(mapping, updated, sc);
result.Updated++; result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}"); Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
} }
} }
} }
// Beide unveraendert -> nichts tun
} }
newMappings.Add(mapping); newMappings.Add(mapping);
@@ -229,14 +425,18 @@ 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() LastOutlook = oc,
LastStarface = updated,
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = updated.GetHash()
}); });
processedStarfaceIds.Add(match.StarfaceId); processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match); unmappedStarface.Remove(match);
@@ -256,7 +456,10 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId, OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId, StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash() LastOutlook = oc,
LastStarface = created,
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = created.GetHash()
}); });
result.Created++; result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}"); Log($" Erstellt (OL->SF): {oc.DisplayName}");
@@ -302,14 +505,18 @@ 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() LastStarface = sc,
LastOutlook = updated,
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = updated.GetHash()
}); });
processedOutlookIds.Add(match.OutlookEntryId); processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match); unmappedOutlook.Remove(match);
@@ -328,7 +535,10 @@ namespace StarfaceOutlookSync.Services
ProfileId = profile.Id, ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId, OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId, StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash() LastStarface = sc,
LastOutlook = created,
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = created.GetHash()
}); });
result.Created++; result.Created++;
Log($" Erstellt (SF->OL): {sc.DisplayName}"); Log($" Erstellt (SF->OL): {sc.DisplayName}");
@@ -343,6 +553,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 // Mappings speichern
_profileManager.SaveMappings(profile.Id, newMappings); _profileManager.SaveMappings(profile.Id, newMappings);
_profileManager.UpdateLastSync(profile.Id); _profileManager.UpdateLastSync(profile.Id);
@@ -0,0 +1,120 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Clientuebergreifende Sperre ueber eine Lock-Datei in einem gemeinsamen
/// Verzeichnis (Netzlaufwerk/Share). Verhindert, dass mehrere Arbeitsplaetze
/// gleichzeitig dasselbe Starface-Adressbuch synchronisieren.
///
/// Atomar ueber FileMode.CreateNew: das Anlegen schlaegt fehl, wenn die Datei
/// schon existiert. Die Datei wird waehrend des Syncs offen gehalten
/// (FileShare.None) - stuerzt ein Client ab, gibt das Betriebssystem das
/// Handle frei und ein anderer Client kann die (dann verwaiste) Datei
/// uebernehmen. Eine reine Zeitstempel-Pruefung allein waere nicht sicher.
/// </summary>
public sealed class SyncLock : IDisposable
{
public const string LockFileName = "starface-sync.lock";
// Aelter als das -> als verwaist behandeln und Uebernahme VERSUCHEN.
// Lebt der Eigentuemer noch, schlaegt das Loeschen am offenen Handle fehl,
// die Sperre bleibt also korrekt bestehen.
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(15);
private FileStream _stream;
private readonly string _path;
private SyncLock(string path, FileStream stream)
{
_path = path;
_stream = stream;
}
/// <summary>Sperre, die nichts haelt (wenn kein gemeinsames Verzeichnis konfiguriert/erreichbar ist).</summary>
public static SyncLock NoOp() => new SyncLock(null, null);
/// <summary>
/// Versucht die Sperre zu holen. Gibt null zurueck, wenn gerade ein
/// anderer (lebender) Client synct. heldBy enthaelt - sofern lesbar -
/// Infos zum aktuellen Halter.
/// </summary>
public static SyncLock TryAcquire(string dir, out string heldBy)
{
heldBy = null;
var path = Path.Combine(dir, LockFileName);
var lockObj = TryCreate(path);
if (lockObj != null) return lockObj;
// Existiert bereits. Halter ermitteln und ggf. verwaiste Datei uebernehmen.
heldBy = ReadOwner(path);
if (IsStale(path))
{
try { File.Delete(path); } // scheitert, wenn der Halter noch lebt (Handle offen)
catch { return null; }
return TryCreate(path);
}
return null;
}
private static SyncLock TryCreate(string path)
{
try
{
var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
var info = $"{Environment.MachineName}|{Environment.UserName}|{DateTime.UtcNow:o}|pid{GetPid()}";
var bytes = Encoding.UTF8.GetBytes(info);
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
return new SyncLock(path, fs);
}
catch (IOException) { return null; } // existiert schon
catch (UnauthorizedAccessException) { return null; }
}
private static int GetPid()
{
try { return Process.GetCurrentProcess().Id; } catch { return 0; }
}
private static string ReadOwner(string path)
{
try
{
// Eigene Freigabe, falls der Halter die Datei nur zum Schreiben offen hat.
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var sr = new StreamReader(fs))
{
var raw = sr.ReadToEnd();
var parts = raw.Split('|');
return parts.Length >= 1 ? parts[0] : null;
}
}
catch { return null; }
}
private static bool IsStale(string path)
{
try
{
var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(path);
return age > StaleAfter;
}
catch { return false; }
}
public void Dispose()
{
try { _stream?.Dispose(); } catch { }
_stream = null;
if (!string.IsNullOrEmpty(_path))
{
try { File.Delete(_path); } catch { }
}
}
}
}
@@ -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.14</Version> <Version>0.0.0.27</Version>
<AssemblyVersion>0.0.0.14</AssemblyVersion> <AssemblyVersion>0.0.0.27</AssemblyVersion>
<FileVersion>0.0.0.14</FileVersion> <FileVersion>0.0.0.27</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.14", Text = "Version 0.0.0.27",
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
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace StarfaceOutlookSync.UI
{
/// <summary>
/// Generiert das App-Icon programmatisch (Kontakt-Symbol mit Sync-Pfeilen).
/// Kein externes ICO-File noetig.
/// </summary>
public static class AppIcon
{
private static Icon _icon;
public static Icon GetIcon()
{
if (_icon != null) return _icon;
_icon = CreateIcon(32);
return _icon;
}
public static Icon GetSmallIcon()
{
return CreateIcon(16);
}
private static Icon CreateIcon(int size)
{
using (var bmp = new Bitmap(size, size))
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
if (size >= 32)
DrawIcon32(g);
else
DrawIcon16(g);
return Icon.FromHandle(bmp.GetHicon());
}
}
private static void DrawIcon32(Graphics g)
{
// Hintergrund: abgerundetes Quadrat
using (var bgBrush = new SolidBrush(Color.FromArgb(0, 120, 212)))
{
FillRoundedRect(g, bgBrush, 0, 0, 31, 31, 6);
}
// Person (Kopf)
using (var whiteBrush = new SolidBrush(Color.White))
{
g.FillEllipse(whiteBrush, 11, 4, 10, 10);
// Person (Koerper)
g.FillPie(whiteBrush, 6, 14, 20, 18, 180, 180);
}
// Sync-Pfeile unten rechts
using (var arrowPen = new Pen(Color.FromArgb(120, 255, 120), 2f))
{
arrowPen.StartCap = LineCap.Round;
arrowPen.EndCap = LineCap.ArrowAnchor;
// Pfeil rechts
g.DrawArc(arrowPen, 20, 22, 10, 8, 200, 140);
arrowPen.Color = Color.FromArgb(255, 200, 80);
// Pfeil links
g.DrawArc(arrowPen, 20, 22, 10, 8, 20, 140);
}
}
private static void DrawIcon16(Graphics g)
{
// Hintergrund
using (var bgBrush = new SolidBrush(Color.FromArgb(0, 120, 212)))
{
FillRoundedRect(g, bgBrush, 0, 0, 15, 15, 3);
}
// Person (vereinfacht)
using (var whiteBrush = new SolidBrush(Color.White))
{
g.FillEllipse(whiteBrush, 4, 1, 7, 7);
g.FillPie(whiteBrush, 2, 8, 12, 10, 180, 180);
}
// Kleiner Sync-Indikator
using (var dotBrush = new SolidBrush(Color.FromArgb(120, 255, 120)))
{
g.FillEllipse(dotBrush, 11, 11, 4, 4);
}
}
private static void FillRoundedRect(Graphics g, Brush brush, int x, int y, int w, int h, int r)
{
using (var path = new GraphicsPath())
{
path.AddArc(x, y, r * 2, r * 2, 180, 90);
path.AddArc(x + w - r * 2, y, r * 2, r * 2, 270, 90);
path.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 0, 90);
path.AddArc(x, y + h - r * 2, r * 2, r * 2, 90, 90);
path.CloseFigure();
g.FillPath(brush, path);
}
}
}
}
+119 -15
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using System.Windows.Forms; using System.Windows.Forms;
@@ -22,6 +23,9 @@ namespace StarfaceOutlookSync.UI
private StatusStrip _statusBar; private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel; private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer; private Timer _autoSyncTimer;
// 0 = frei, 1 = Sync laeuft. Per Interlocked atomar geschaltet, damit
// ein manueller Sync und der Auto-Sync-Timer nicht gleichzeitig starten.
private int _syncRunning = 0;
public MainForm() public MainForm()
{ {
@@ -30,14 +34,31 @@ namespace StarfaceOutlookSync.UI
SetupAutoSync(); SetupAutoSync();
RefreshProfileList(); RefreshProfileList();
// Minimiert starten falls in Einstellungen aktiviert // Einstellungen laden und anwenden
var settings = UserSettings.Load(); var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
if (settings.StartMinimized) if (settings.StartMinimized)
{ {
WindowState = FormWindowState.Minimized; WindowState = FormWindowState.Minimized;
ShowInTaskbar = false; ShowInTaskbar = false;
Visible = false; Visible = false;
} }
// Beim Start automatisch synchronisieren
if (settings.SyncOnStart)
{
_ = SyncAllProfiles();
}
}
private async Task SyncAllProfiles()
{
var profiles = _profileManager.GetProfiles().Where(p => p.Enabled).ToList();
foreach (var profile in profiles)
{
await RunSync(profile);
}
} }
protected override void SetVisibleCore(bool value) protected override void SetVisibleCore(bool value)
@@ -58,10 +79,11 @@ 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();
// Profil-Liste // Profil-Liste
_profileList = new ListView _profileList = new ListView
@@ -124,10 +146,26 @@ namespace StarfaceOutlookSync.UI
private void SetupTrayIcon() private void SetupTrayIcon()
{ {
_trayMenu = new ContextMenuStrip(); _trayMenu = new ContextMenuStrip();
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = AppIcon.GetSmallIcon(),
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
UpdateTrayMenu();
}
private void UpdateTrayMenu()
{
_trayMenu.Items.Clear();
_trayMenu.Items.Add("Oeffnen", null, (s, e) => ShowMainWindow()); _trayMenu.Items.Add("Oeffnen", null, (s, e) => ShowMainWindow());
_trayMenu.Items.Add("-"); _trayMenu.Items.Add("-");
// Schnell-Sync fuer jedes Profil
var profiles = _profileManager.GetProfiles(); var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled)) foreach (var p in profiles.Where(p => p.Enabled))
{ {
@@ -141,17 +179,8 @@ namespace StarfaceOutlookSync.UI
if (profiles.Any(p => p.Enabled)) if (profiles.Any(p => p.Enabled))
_trayMenu.Items.Add("-"); _trayMenu.Items.Add("-");
_trayMenu.Items.Add("Ueber", null, (s, e) => ShowAbout());
_trayMenu.Items.Add("Beenden", null, (s, e) => ExitApplication()); _trayMenu.Items.Add("Beenden", null, (s, e) => ExitApplication());
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = SystemIcons.Application, // Placeholder, wird durch eigenes Icon ersetzt
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
} }
private void SetupAutoSync() private void SetupAutoSync()
@@ -219,7 +248,7 @@ namespace StarfaceOutlookSync.UI
} }
// Tray-Menu aktualisieren // Tray-Menu aktualisieren
SetupTrayIcon(); UpdateTrayMenu();
} }
private void NewProfile() private void NewProfile()
@@ -311,8 +340,25 @@ namespace StarfaceOutlookSync.UI
private async Task RunSync(SyncProfile profile) private async Task RunSync(SyncProfile profile)
{ {
// Atomar pruefen-und-setzen: verhindert, dass manueller Sync und
// Auto-Sync-Timer gleichzeitig denselben/einen Sync starten.
if (Interlocked.CompareExchange(ref _syncRunning, 1, 0) != 0)
{
SetStatus("Sync laeuft bereits, bitte warten...");
return;
}
SyncLock crossLock = null;
try try
{ {
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
crossLock = await AcquireCrossClientLock();
if (crossLock == null)
{
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
return;
}
SetStatus($"Synchronisiere '{profile.Name}'..."); SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync", _trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info); $"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
@@ -321,10 +367,22 @@ namespace StarfaceOutlookSync.UI
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert"; var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert";
if (result.Errors > 0) msg += $", {result.Errors} Fehler"; if (result.Errors > 0) msg += $", {result.Errors} Fehler";
if (result.Conflicts.Count > 0) msg += $", {result.Conflicts.Count} Konflikt(e)";
_trayIcon.ShowBalloonTip(3000, "Starface Sync", msg, _trayIcon.ShowBalloonTip(3000, "Starface Sync", msg,
result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info); result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info);
// Echte Feld-Konflikte gesondert melden, damit der Benutzer weiss,
// dass ein Wert ueberschrieben wurde.
if (result.Conflicts.Count > 0)
{
var detail = string.Join("\n", result.Conflicts.Take(5).Select(c => c.ToString()));
if (result.Conflicts.Count > 5)
detail += $"\n... und {result.Conflicts.Count - 5} weitere";
_trayIcon.ShowBalloonTip(10000,
$"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning);
}
SetStatus(msg); SetStatus(msg);
} }
catch (Exception ex) catch (Exception ex)
@@ -333,6 +391,52 @@ namespace StarfaceOutlookSync.UI
ex.Message, ToolTipIcon.Error); ex.Message, ToolTipIcon.Error);
SetStatus($"Fehler: {ex.Message}"); SetStatus($"Fehler: {ex.Message}");
} }
finally
{
crossLock?.Dispose();
Interlocked.Exchange(ref _syncRunning, 0);
}
}
/// <summary>
/// Holt die clientuebergreifende Lock-Datei aus dem konfigurierten
/// gemeinsamen Verzeichnis. Wartet bis zu 2 Minuten, falls ein anderer
/// Arbeitsplatz gerade synct. Gibt null zurueck, wenn die Sperre nicht
/// erlangt werden konnte (-> diesen Lauf ueberspringen).
/// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird
/// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt).
/// </summary>
private async Task<SyncLock> AcquireCrossClientLock()
{
var dir = UserSettings.Load().SharedDirectory;
if (string.IsNullOrWhiteSpace(dir))
return SyncLock.NoOp();
try
{
// Legt das Verzeichnis bei Bedarf an (idempotent). Funktioniert mit
// UNC-Pfaden (\\server\freigabe\...) - kein Netzlaufwerk noetig.
System.IO.Directory.CreateDirectory(dir);
}
catch
{
SetStatus("Gemeinsames Verzeichnis nicht erreichbar - synce ohne Sperre.");
return SyncLock.NoOp();
}
var deadline = DateTime.UtcNow.AddMinutes(2);
int attempt = 0;
while (true)
{
var l = SyncLock.TryAcquire(dir, out var heldBy);
if (l != null) return l;
if (DateTime.UtcNow >= deadline) return null;
attempt++;
SetStatus($"Warte auf anderen Arbeitsplatz ({heldBy ?? "unbekannt"})... ({attempt})");
await Task.Delay(3000);
}
} }
private void SetStatus(string text) private void SetStatus(string text)
@@ -22,6 +22,7 @@ namespace StarfaceOutlookSync.UI
private NumericUpDown _numAutoSync; private NumericUpDown _numAutoSync;
private Button _btnTest, _btnLoadBooks, _btnSave, _btnCancel; private Button _btnTest, _btnLoadBooks, _btnSave, _btnCancel;
private Label _lblTestResult; private Label _lblTestResult;
private Label _lblDirectionHint;
private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>(); private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>();
private List<string> _outlookFolderPaths = new List<string>(); 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 = new ComboBox { Left = 12, Top = y, Width = 250, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" }); _cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" });
_cmbDirection.SelectedIndex = 0; _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; 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 }; _numAutoSync = new NumericUpDown { Left = 12, Top = y, Width = 80, Minimum = 0, Maximum = 1440, Value = 0 };
@@ -122,6 +134,22 @@ namespace StarfaceOutlookSync.UI
CancelButton = _btnCancel; 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) private Label MakeLabel(string text, int x, int y)
{ {
return new Label { Text = text, Left = x, Top = y, AutoSize = true }; return new Label { Text = text, Left = x, Top = y, AutoSize = true };
@@ -333,9 +361,23 @@ namespace StarfaceOutlookSync.UI
}; };
if (_isNew) if (_isNew)
{
_pm.AddProfile(profile); _pm.AddProfile(profile);
}
else else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.StarfaceAddressBook?.TagId != profile.StarfaceAddressBook?.TagId
|| _existingProfile.StarfaceAddressBook?.Type != profile.StarfaceAddressBook?.Type)
{
_pm.SaveMappings(profile.Id, new List<SyncMapping>());
profile.LastSync = "";
MessageBox.Show(
"Adressbuch wurde geaendert.\nSync-Zuordnungen wurden automatisch zurueckgesetzt.",
"Adressbuch geaendert", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
_pm.UpdateProfile(profile); _pm.UpdateProfile(profile);
}
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
+78 -5
View File
@@ -6,7 +6,9 @@ namespace StarfaceOutlookSync.UI
{ {
public class SettingsForm : Form public class SettingsForm : Form
{ {
private CheckBox _chkStartMinimized; private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private TextBox _txtSharedDir;
private Button _btnBrowseShared;
private Button _btnSave, _btnCancel; private Button _btnSave, _btnCancel;
private readonly UserSettings _settings; private readonly UserSettings _settings;
@@ -19,7 +21,7 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent() private void InitializeComponent()
{ {
Text = "Einstellungen"; Text = "Einstellungen";
Size = new Size(350, 180); Size = new Size(380, 250);
FormBorderStyle = FormBorderStyle.FixedDialog; FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false; MaximizeBox = false;
MinimizeBox = false; MinimizeBox = false;
@@ -33,27 +35,98 @@ namespace StarfaceOutlookSync.UI
Checked = _settings.StartMinimized Checked = _settings.StartMinimized
}; };
_chkSyncOnStart = new CheckBox
{
Text = "Beim Start automatisch synchronisieren",
Left = 20, Top = 52, AutoSize = true,
Checked = _settings.SyncOnStart
};
_chkAutoAcceptOutlook = new CheckBox
{
Text = "Outlook-Sicherheitsabfrage automatisch erlauben",
Left = 20, Top = 80, AutoSize = true,
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 = 320, Height = 36,
ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray,
Font = new Font("Segoe UI", 8)
};
var lblShared = new Label
{
Text = "Gemeinsames Verzeichnis fuer Sync-Sperre (Mehrplatz, optional):",
Left = 20, Top = 150, AutoSize = true
};
_txtSharedDir = new TextBox
{
Left = 20, Top = 172, Width = 250,
Text = _settings.SharedDirectory
};
_btnBrowseShared = new Button
{
Text = "...", Left = 274, Top = 171, Width = 36, Height = 24
};
_btnBrowseShared.Click += (s, e) => BrowseSharedDir();
var lblSharedHint = new Label
{
Text = "Netzlaufwerk/UNC, das alle Arbeitsplaetze erreichen. Leer = keine\n" +
"clientuebergreifende Sperre (nur Schutz auf diesem PC).",
Left = 20, Top = 198, Width = 330, Height = 32,
ForeColor = Color.Gray, Font = new Font("Segoe UI", 8)
};
_btnSave = new Button _btnSave = new Button
{ {
Text = "Speichern", Left = 80, Top = 100, Width = 85, Height = 28, Text = "Speichern", Left = 95, Top = 240, 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 = 174, Top = 100, Width = 85, Height = 28, Text = "Abbrechen", Left = 189, Top = 240, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel DialogResult = DialogResult.Cancel
}; };
Controls.AddRange(new Control[] { _chkStartMinimized, _btnSave, _btnCancel }); Size = new Size(380, 330);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint,
lblShared, _txtSharedDir, _btnBrowseShared, lblSharedHint, _btnSave, _btnCancel });
AcceptButton = _btnSave; AcceptButton = _btnSave;
CancelButton = _btnCancel; CancelButton = _btnCancel;
} }
private void BrowseSharedDir()
{
using (var dlg = new FolderBrowserDialog())
{
dlg.Description = "Gemeinsames Verzeichnis fuer die Sync-Sperre waehlen";
if (!string.IsNullOrWhiteSpace(_txtSharedDir.Text))
{
try { dlg.SelectedPath = _txtSharedDir.Text; } catch { }
}
if (dlg.ShowDialog(this) == DialogResult.OK)
_txtSharedDir.Text = dlg.SelectedPath;
}
}
private void Save() private void Save()
{ {
_settings.StartMinimized = _chkStartMinimized.Checked; _settings.StartMinimized = _chkStartMinimized.Checked;
_settings.SyncOnStart = _chkSyncOnStart.Checked;
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
_settings.SharedDirectory = _txtSharedDir.Text.Trim();
_settings.Save(); _settings.Save();
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();