Compare commits

..

55 Commits

Author SHA1 Message Date
duffyduck 96c34e542f Release v0.0.0.29 2026-06-08 13:18:35 +02:00
duffyduck 23223fe0be Add persistent log file and "Protokoll" viewer button
Tray-Meldungen verschwinden - daher ein dauerhaftes Protokoll.

- Logger: threadsicheres Datei-Log in %AppData%\StarfaceOutlookSync\sync.log
  mit Rotation bei 2 MB.
- MainForm protokolliert Sync-Start, Ergebnis, Konflikte (lokal + von anderen
  Arbeitsplaetzen), Fehler und uebersprungene Laeufe (Sperre).
- Neuer Button "Protokoll" oeffnet LogViewerForm (Aktualisieren/Leeren/Ordner
  oeffnen, scrollt ans Ende).
- README/CHANGELOG aktualisiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:17:53 +02:00
duffyduck b8a7fda7bf Release v0.0.0.28 2026-06-08 13:02:51 +02:00
duffyduck 257b6fb33d Only notify clients actually affected by a conflict
Bisher bekam jeder Arbeitsplatz, der den Konflikt-Kontakt im Adressbuch hat,
den Hinweis - auch wenn er den Wert gar nicht selbst gepflegt hat.

Jetzt zeigt ein Client eine fremde Konflikt-Notiz nur, wenn er den Kontakt hat
UND sein eigener Feldwert vom uebernommenen (Gewinner-)Wert abweicht. Die
Pruefung laeuft VOR dem Sync (gegen den eigenen Mapping-Snapshot), bevor der
Sync den Stand auf den Gewinner-Wert angleicht.

- FieldConflict/ConflictNotice: stabiler FieldKey zusaetzlich zum Anzeige-Label.
- ContactMerger: GetValue/ValuesEqual per FieldKey (telefon-normalisiert).
- ConflictNotifier.GetPending: Filter "eigener Wert != Gewinner-Wert", bekommt
  StarfaceId -> eigener Kontaktstand.
- MainForm zeigt die Hinweise jetzt vor dem Sync und liefert den eigenen Stand
  aus den Mapping-Snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:01:02 +02:00
duffyduck cf2970a41a Add cross-client conflict notifications (step 3)
Wird ein echter Feld-Konflikt aufgeloest, erfahren jetzt auch die ANDEREN
Arbeitsplaetze davon - nicht nur der aufloesende Client.

- ConflictNotice-Modell + ConflictNotifier: schreibt pro Konflikt eine Notiz
  in <shared>/conflicts/ (nach StarfaceId), liest beim Sync ungesehene Notizen
  zu eigenen Kontakten, zeigt sie als Tray-Hinweis und merkt sich gezeigte
  lokal (seen-conflicts.json). Veraltete Notizen (>7 Tage) werden aufgeraeumt.
- MainForm: schreibt nach einem Sync mit Konflikten die Notizen und zeigt
  ausstehende Notizen anderer Clients (gefiltert auf eigene gemappte
  StarfaceIds). AcquireCrossClientLock nimmt jetzt das gemeinsame Verzeichnis
  als Parameter.
- README/CHANGELOG aktualisiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:54:01 +02:00
duffyduck 5a0203e49e Document multi-client sync lock in README
Beschreibt Lock-Datei starface-sync.lock: atomares Anlegen (CreateNew),
nur waehrend des Syncs vorhanden, Inhalt (Host/User/Zeit/PID), Warte- und
Absturz-Verhalten, sowie den Hinweis auf bidirektionalen Modus im Mehrplatz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:47:28 +02:00
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
duffyduck 2b9ad5bf3c Release v0.0.0.14 2026-04-03 18:13:48 +02:00
duffyduck 8a316600b5 Add debug logging for Starface create/update failures
Log the full request body and response when POST /contacts fails
so we can see why new contacts are not being created.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:50:16 +02:00
duffyduck 3c38a1a6cc Release v0.0.0.10 2026-04-03 12:17:30 +02:00
21 changed files with 2298 additions and 261 deletions
+119
View File
@@ -0,0 +1,119 @@
# 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
- **Protokoll (Logdatei) + "Protokoll"-Button.** Syncs, Ergebnisse, Konflikte
(lokal und von anderen Arbeitsplaetzen) und Fehler werden dauerhaft in
`%AppData%\StarfaceOutlookSync\sync.log` festgehalten - so ist auch nach dem
Verschwinden einer Tray-Meldung nachvollziehbar, wer/was/wann. Im Hauptfenster
oeffnet der Button "Protokoll" einen Betrachter (Aktualisieren/Leeren/Ordner
oeffnen). Die Datei rotiert bei 2 MB.
- **Clientuebergreifende Konflikt-Hinweise (Mehrplatz).** Wird ein echter
Feld-Konflikt aufgeloest, legt der Client eine Notiz im gemeinsamen Verzeichnis
(`conflicts/`) ab - zugeordnet nach Kontakt (StarfaceId). Ein anderer
Arbeitsplatz zeigt die Meldung beim naechsten Sync nur an, wenn er den Kontakt
selbst hat UND sein eigener Feldwert vom uebernommenen Wert abweicht - so wird
der ueberschriebene Arbeitsplatz gewarnt, waehrend bereits aktuelle Clients
nicht benachrichtigt werden. Bereits gezeigte Notizen merkt sich jeder Client
lokal; alte Notizen werden nach 7 Tagen aufgeraeumt.
- **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.
+79 -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.
@@ -231,11 +260,60 @@ StarfaceOutlookSync.exe
Alle Daten werden lokal pro Benutzer gespeichert:
- `%AppData%\StarfaceOutlookSync\profiles.json` - Sync-Profile mit Zugangsdaten
- `%AppData%\StarfaceOutlookSync\mappings\` - Kontakt-Zuordnungen pro Profil
- `%AppData%\StarfaceOutlookSync\sync.log` - Protokoll (Syncs, Konflikte, Fehler);
im Hauptfenster ueber den Button **Protokoll** einsehbar
### SSL-Zertifikate
Self-signed Zertifikate der Starface werden automatisch akzeptiert. Es ist kein manueller Zertifikat-Import erforderlich.
### Mehrplatz-Betrieb / Sync-Sperre
Mehrere Arbeitsplaetze koennen dasselbe Starface-Adressbuch pflegen. Damit sich
gleichzeitig laufende Syncs nicht ins Gehege kommen (Dubletten, ueberschriebene
Aenderungen), gibt es zwei Schutzebenen:
**1. Lokal (immer aktiv):** Auf einem PC kann nie mehr als ein Sync gleichzeitig
laufen. Startet der Auto-Sync-Timer, waehrend ein manueller Sync laeuft (oder
umgekehrt), wird der zweite Lauf uebersprungen. Atomar per `Interlocked`.
**2. Clientuebergreifend (optional):** In den Einstellungen kann ein
**gemeinsames Verzeichnis** (Netzlaufwerk oder UNC-Pfad wie
`\\server\freigabe\sync`) hinterlegt werden. Funktionsweise:
- Beim Start eines Syncs legt der Client dort die Datei **`starface-sync.lock`**
an - **atomar** ueber `FileMode.CreateNew`: das Anlegen schlaegt fehl, wenn
die Datei bereits existiert. Damit kann immer nur ein Client gleichzeitig die
Sperre halten.
- Die Datei wird waehrend des gesamten Syncs **offen gehalten** und am Ende
wieder **geloescht**. Sie existiert also nur, solange tatsaechlich gerade
synchronisiert wird (bei kleinen Adressbuechern nur sehr kurz).
- Inhalt der Datei: `Rechnername | Benutzer | UTC-Zeitstempel | Prozess-ID` -
damit man sieht, wer gerade synct.
- Findet ein anderer Client die Sperre vor, **wartet** er (Abfrage alle 3
Sekunden, bis zu 2 Minuten) und synct erst danach. Wird die Sperre nicht frei,
wird der Lauf uebersprungen und beim naechsten Intervall erneut versucht.
- **Absturzsicher:** Stuerzt ein Client mitten im Sync ab, gibt Windows das
Datei-Handle frei. Die verwaiste Lock-Datei gilt nach 15 Minuten als veraltet
und wird vom naechsten Client uebernommen. Lebt der Halter noch, scheitert das
Loeschen am offenen Handle - die Sperre bleibt also korrekt bestehen.
- Ist **kein** Verzeichnis konfiguriert oder es ist nicht erreichbar, laeuft der
Sync ohne diese Sperre weiter (die lokale Sperre bleibt aktiv).
**Konflikt-Hinweise:** Wird im bidirektionalen Modus ein echter Feld-Konflikt
aufgeloest (dasselbe Feld auf beiden Seiten unterschiedlich geaendert, Outlook
gewinnt), legt der Client eine Notiz im Unterordner `conflicts/` des gemeinsamen
Verzeichnisses ab - zugeordnet ueber die StarfaceId des Kontakts. Beim naechsten
Sync zeigt ein anderer Arbeitsplatz die Meldung als Tray-Hinweis - aber **nur,
wenn er den Kontakt selbst hat UND sein eigener Feldwert vom uebernommenen Wert
abweicht** (so erfaehrt der ueberschriebene Arbeitsplatz davon, waehrend Clients,
die den Wert ohnehin schon haben, nicht benachrichtigt werden). Gezeigte Notizen
merkt sich jeder Client lokal, veraltete Notizen werden nach 7 Tagen entfernt.
> Hinweis: Im Mehrplatz-Betrieb sollte die **bidirektionale** Sync-Richtung
> verwendet werden. Die Ein-Richtungs-Modi ("Ersetzen") wuerden die von anderen
> Arbeitsplaetzen gepflegten Kontakte loeschen.
## Lizenz
Proprietaer - Alle Rechte vorbehalten.
+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.9"
#define MyAppVersion "0.0.0.29"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"
@@ -0,0 +1,26 @@
namespace StarfaceOutlookSync.Models
{
/// <summary>
/// Hinweis ueber einen aufgeloesten Feld-Konflikt, der im gemeinsamen
/// Verzeichnis abgelegt wird, damit auch andere Arbeitsplaetze (die denselben
/// Kontakt pflegen) erfahren, dass ein Wert ueberschrieben wurde.
/// </summary>
public class ConflictNotice
{
public string Id { get; set; } = "";
public string ByHost { get; set; } = "";
public string ByUser { get; set; } = "";
public string TimestampUtc { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string ContactName { get; set; } = "";
public string Field { get; set; } = "";
public string FieldKey { get; set; } = "";
public string OutlookValue { get; set; } = "";
public string StarfaceValue { get; set; } = "";
public string Winner { get; set; } = "";
public override string ToString() =>
$"{ContactName}: Feld '{Field}' geaendert an Arbeitsplatz {ByHost} " +
$"(Outlook: '{OutlookValue}' / Starface: '{StarfaceValue}') -> {Winner} uebernommen";
}
}
@@ -45,6 +45,22 @@ 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; } = "";
// 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; } = "";
}
@@ -56,5 +72,24 @@ namespace StarfaceOutlookSync.Models
public int Updated { get; set; }
public int Errors { get; set; }
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; } = ""; // Anzeige-Label, z.B. "E-Mail"
public string FieldKey { get; set; } = ""; // stabiler Schluessel, z.B. "Email"
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.IO;
using Microsoft.Win32;
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
@@ -7,6 +8,13 @@ namespace StarfaceOutlookSync.Models
public class UserSettings
{
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(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -32,6 +40,86 @@ namespace StarfaceOutlookSync.Models
File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(this, Formatting.Indented));
}
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,178 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Verteilt Konflikt-Hinweise ueber das gemeinsame Verzeichnis an alle
/// Arbeitsplaetze. Wer einen echten Feld-Konflikt aufloest, legt eine Notiz
/// ab; andere Clients zeigen beim naechsten Sync die Notizen zu Kontakten,
/// die sie selbst gemappt haben (per StarfaceId). Bereits gezeigte Notizen
/// merkt sich jeder Client lokal, damit sie nicht doppelt erscheinen.
/// </summary>
public class ConflictNotifier
{
private static readonly TimeSpan Ttl = TimeSpan.FromDays(7);
private readonly string _seenFile;
public ConflictNotifier()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"StarfaceOutlookSync");
try { Directory.CreateDirectory(dir); } catch { }
_seenFile = Path.Combine(dir, "seen-conflicts.json");
}
private static string ConflictsDir(string sharedDir) => Path.Combine(sharedDir, "conflicts");
/// <summary>Legt fuer jeden Konflikt eine Notiz im gemeinsamen Verzeichnis ab.</summary>
public void Write(string sharedDir, IEnumerable<FieldConflict> conflicts)
{
if (string.IsNullOrWhiteSpace(sharedDir)) return;
var list = conflicts?.ToList();
if (list == null || list.Count == 0) return;
try
{
var dir = ConflictsDir(sharedDir);
Directory.CreateDirectory(dir);
var host = Environment.MachineName;
var user = Environment.UserName;
var ts = DateTime.UtcNow.ToString("o");
foreach (var c in list)
{
var notice = new ConflictNotice
{
Id = Guid.NewGuid().ToString("N"),
ByHost = host,
ByUser = user,
TimestampUtc = ts,
StarfaceId = c.StarfaceId,
ContactName = c.ContactName,
Field = c.Field,
FieldKey = c.FieldKey,
OutlookValue = c.OutlookValue,
StarfaceValue = c.StarfaceValue,
Winner = c.Winner
};
try
{
File.WriteAllText(Path.Combine(dir, notice.Id + ".json"),
JsonConvert.SerializeObject(notice, Formatting.Indented));
}
catch { }
}
}
catch { }
}
/// <summary>
/// Liefert ungesehene Konflikt-Notizen, die Kontakte dieses Clients
/// betreffen und nicht von ihm selbst stammen. Es werden nur die Notizen
/// zurueckgegeben, bei denen der EIGENE aktuelle Feldwert vom uebernommenen
/// (Gewinner-)Wert abweicht - wer den Wert ohnehin schon hat, wird nicht
/// benachrichtigt. myContacts bildet StarfaceId -> eigener Kontaktstand ab.
/// Markiert verarbeitete Notizen als gesehen und raeumt veraltete auf.
/// </summary>
public List<ConflictNotice> GetPending(string sharedDir, IDictionary<string, UnifiedContact> myContacts)
{
var result = new List<ConflictNotice>();
if (string.IsNullOrWhiteSpace(sharedDir)) return result;
var dir = ConflictsDir(sharedDir);
string[] files;
try
{
if (!Directory.Exists(dir)) return result;
files = Directory.GetFiles(dir, "*.json");
}
catch { return result; }
var seen = LoadSeen();
var host = Environment.MachineName;
var existingIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var f in files)
{
ConflictNotice n = null;
try { n = JsonConvert.DeserializeObject<ConflictNotice>(File.ReadAllText(f)); }
catch { }
if (n == null || string.IsNullOrEmpty(n.Id)) continue;
// Veraltete Notizen entfernen.
if (DateTime.TryParse(n.TimestampUtc, out var ts)
&& DateTime.UtcNow - ts.ToUniversalTime() > Ttl)
{
try { File.Delete(f); } catch { }
continue;
}
existingIds.Add(n.Id);
if (seen.Contains(n.Id)) continue; // schon gezeigt
if (string.Equals(n.ByHost, host, StringComparison.OrdinalIgnoreCase))
{
seen.Add(n.Id); // selbst erzeugt -> nicht erneut anzeigen
continue;
}
// Habe ich diesen Kontakt ueberhaupt?
UnifiedContact mine = null;
bool haveContact = !string.IsNullOrEmpty(n.StarfaceId)
&& myContacts != null
&& myContacts.TryGetValue(n.StarfaceId, out mine);
if (!haveContact)
continue; // betrifft mich nicht (kein seen -> evtl. spaeter relevant)
// Bin ich wirklich betroffen? Nur wenn mein aktueller Feldwert vom
// uebernommenen Wert abweicht. Wer den Gewinner-Wert schon hat, wird
// nicht benachrichtigt.
if (mine != null && !string.IsNullOrEmpty(n.FieldKey))
{
var winnerValue = string.Equals(n.Winner, "Outlook", StringComparison.OrdinalIgnoreCase)
? n.OutlookValue : n.StarfaceValue;
var myValue = ContactMerger.GetValue(mine, n.FieldKey);
if (ContactMerger.ValuesEqual(n.FieldKey, myValue, winnerValue))
{
seen.Add(n.Id); // nicht betroffen -> als erledigt merken
continue;
}
}
result.Add(n);
seen.Add(n.Id);
}
// Gesehen-Liste auf noch vorhandene Notizen eindampfen.
seen.IntersectWith(existingIds);
SaveSeen(seen);
return result;
}
private HashSet<string> LoadSeen()
{
try
{
if (File.Exists(_seenFile))
return new HashSet<string>(
JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(_seenFile)) ?? new List<string>(),
StringComparer.OrdinalIgnoreCase);
}
catch { }
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
private void SaveSeen(HashSet<string> seen)
{
try { File.WriteAllText(_seenFile, JsonConvert.SerializeObject(seen.ToList())); }
catch { }
}
}
}
@@ -0,0 +1,146 @@
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 Key;
public string Label;
public Func<UnifiedContact, string> Get;
public Action<UnifiedContact, string> Set;
public bool IsPhone;
}
private static readonly FieldDef[] Fields = new[]
{
new FieldDef { Key = "FirstName", Label = "Vorname", Get = c => c.FirstName, Set = (c, v) => c.FirstName = v },
new FieldDef { Key = "LastName", Label = "Nachname", Get = c => c.LastName, Set = (c, v) => c.LastName = v },
new FieldDef { Key = "Company", Label = "Firma", Get = c => c.Company, Set = (c, v) => c.Company = v },
new FieldDef { Key = "JobTitle", Label = "Position", Get = c => c.JobTitle, Set = (c, v) => c.JobTitle = v },
new FieldDef { Key = "Email", Label = "E-Mail", Get = c => c.Email, Set = (c, v) => c.Email = v },
new FieldDef { Key = "EmailSecondary", Label = "E-Mail 2", Get = c => c.EmailSecondary, Set = (c, v) => c.EmailSecondary = v },
new FieldDef { Key = "PhoneWork", Label = "Telefon", Get = c => c.PhoneWork, Set = (c, v) => c.PhoneWork = v, IsPhone = true },
new FieldDef { Key = "PhoneMobile", Label = "Mobil", Get = c => c.PhoneMobile, Set = (c, v) => c.PhoneMobile = v, IsPhone = true },
new FieldDef { Key = "PhoneHome", Label = "Telefon privat", Get = c => c.PhoneHome, Set = (c, v) => c.PhoneHome = v, IsPhone = true },
new FieldDef { Key = "Fax", Label = "Fax", Get = c => c.Fax, Set = (c, v) => c.Fax = v, IsPhone = true },
new FieldDef { Key = "Street", Label = "Strasse", Get = c => c.Street, Set = (c, v) => c.Street = v },
new FieldDef { Key = "City", Label = "Ort", Get = c => c.City, Set = (c, v) => c.City = v },
new FieldDef { Key = "PostalCode", Label = "PLZ", Get = c => c.PostalCode, Set = (c, v) => c.PostalCode = v },
new FieldDef { Key = "State", Label = "Bundesland", Get = c => c.State, Set = (c, v) => c.State = v },
new FieldDef { Key = "Country", Label = "Land", Get = c => c.Country, Set = (c, v) => c.Country = v },
new FieldDef { Key = "Website", Label = "Webseite", Get = c => c.Website, Set = (c, v) => c.Website = v },
new FieldDef { Key = "Notes", Label = "Notizen", Get = c => c.Notes, Set = (c, v) => c.Notes = v },
new FieldDef { Key = "Salutation", Label = "Anrede", Get = c => c.Salutation, Set = (c, v) => c.Salutation = v },
new FieldDef { Key = "Title", Label = "Titel", Get = c => c.Title, Set = (c, v) => c.Title = v },
new FieldDef { Key = "Birthday", Label = "Geburtstag", Get = c => c.Birthday, Set = (c, v) => c.Birthday = v },
};
/// <summary>Liest den Wert eines Feldes per stabilem Schluessel.</summary>
public static string GetValue(UnifiedContact c, string key)
{
if (c == null || string.IsNullOrEmpty(key)) return "";
var f = Fields.FirstOrDefault(x => x.Key == key);
return f == null ? "" : (f.Get(c) ?? "");
}
/// <summary>Vergleicht zwei Feldwerte (telefon-normalisiert je nach Feld).</summary>
public static bool ValuesEqual(string key, string a, string b)
{
var f = Fields.FirstOrDefault(x => x.Key == key);
return Equal(a ?? "", b ?? "", f?.IsPhone ?? false);
}
/// <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,
FieldKey = f.Key,
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());
}
}
}
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Text;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Einfaches, threadsicheres Datei-Protokoll. Haelt fest, was bei Syncs
/// passiert (Ergebnisse, Konflikte, Fehler) - dauerhaft nachlesbar, auch
/// nachdem eine Tray-Meldung verschwunden ist.
/// </summary>
public static class Logger
{
private static readonly object _lock = new object();
private const long MaxBytes = 2 * 1024 * 1024; // 2 MB, dann Rotation
public static string LogFilePath { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"StarfaceOutlookSync", "sync.log");
public static void Log(string message)
{
try
{
lock (_lock)
{
Directory.CreateDirectory(Path.GetDirectoryName(LogFilePath));
Rotate();
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}";
File.AppendAllText(LogFilePath, line, Encoding.UTF8);
}
}
catch { }
}
private static void Rotate()
{
try
{
var fi = new FileInfo(LogFilePath);
if (fi.Exists && fi.Length > MaxBytes)
{
var bak = LogFilePath + ".1";
if (File.Exists(bak)) File.Delete(bak);
File.Move(LogFilePath, bak);
}
}
catch { }
}
public static string ReadAll()
{
try
{
lock (_lock)
{
if (!File.Exists(LogFilePath)) return "";
using (var fs = new FileStream(LogFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var sr = new StreamReader(fs, Encoding.UTF8))
return sr.ReadToEnd();
}
}
catch { return ""; }
}
public static void Clear()
{
try
{
lock (_lock)
{
if (File.Exists(LogFilePath)) File.Delete(LogFilePath);
var bak = LogFilePath + ".1";
if (File.Exists(bak)) File.Delete(bak);
}
}
catch { }
}
}
}
@@ -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;
}
}
@@ -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)
{
return new UnifiedContact
{
OutlookEntryId = (string)(ci.EntryID ?? ""),
FirstName = (string)(ci.FirstName ?? ""),
LastName = (string)(ci.LastName ?? ""),
Company = (string)(ci.CompanyName ?? ""),
JobTitle = (string)(ci.JobTitle ?? ""),
Email = (string)(ci.Email1Address ?? ""),
EmailSecondary = (string)(ci.Email2Address ?? ""),
PhoneWork = (string)(ci.BusinessTelephoneNumber ?? ""),
PhoneMobile = (string)(ci.MobileTelephoneNumber ?? ""),
PhoneHome = (string)(ci.HomeTelephoneNumber ?? ""),
Fax = (string)(ci.BusinessFaxNumber ?? ""),
Street = (string)(ci.BusinessAddressStreet ?? ""),
City = (string)(ci.BusinessAddressCity ?? ""),
PostalCode = (string)(ci.BusinessAddressPostalCode ?? ""),
State = (string)(ci.BusinessAddressState ?? ""),
Country = (string)(ci.BusinessAddressCountry ?? ""),
Website = (string)(ci.WebPage ?? ""),
Notes = (string)(ci.Body ?? ""),
Salutation = (string)(ci.Title ?? ""),
OutlookEntryId = SafeGet(ci, "EntryID"),
FirstName = SafeGet(ci, "FirstName"),
LastName = SafeGet(ci, "LastName"),
Company = SafeGet(ci, "CompanyName"),
JobTitle = SafeGet(ci, "JobTitle"),
Email = SafeGet(ci, "Email1Address"),
EmailSecondary = SafeGet(ci, "Email2Address"),
PhoneWork = SafeGet(ci, "BusinessTelephoneNumber"),
PhoneMobile = SafeGet(ci, "MobileTelephoneNumber"),
PhoneHome = SafeGet(ci, "HomeTelephoneNumber"),
Fax = SafeGet(ci, "BusinessFaxNumber"),
Street = SafeGet(ci, "BusinessAddressStreet"),
City = SafeGet(ci, "BusinessAddressCity"),
PostalCode = SafeGet(ci, "BusinessAddressPostalCode"),
State = SafeGet(ci, "BusinessAddressState"),
Country = SafeGet(ci, "BusinessAddressCountry"),
Website = SafeGet(ci, "WebPage"),
Notes = SafeGet(ci, "Body"),
Salutation = SafeGet(ci, "Title"),
Birthday = GetBirthdayString(ci)
};
}
@@ -425,8 +444,9 @@ namespace StarfaceOutlookSync.Services
{
try
{
DateTime bday = ci.Birthday;
if (bday.Year > 1900 && bday != DateTime.MinValue)
object val = ci.GetType().InvokeMember("Birthday",
System.Reflection.BindingFlags.GetProperty, null, ci, null);
if (val is DateTime bday && bday.Year > 1900 && bday != DateTime.MinValue)
return bday.ToString("yyyy-MM-dd");
}
catch { }
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
@@ -115,43 +116,63 @@ namespace StarfaceOutlookSync.Services
{
var books = new List<StarfaceAddressBook>();
books.Add(new StarfaceAddressBook
{
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
// Alle Tags laden - die Starface nutzt Tags als Adressbuch-Zuordnung
var allTags = new JArray();
try
{
var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags");
if (resp.IsSuccessStatusCode)
{
var tags = JArray.Parse(await resp.Content.ReadAsStringAsync());
foreach (var tag in tags)
{
books.Add(new StarfaceAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = $"Tag: {tag["name"]}"
});
}
allTags = JArray.Parse(await resp.Content.ReadAsStringAsync());
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
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = tagName
});
}
return books;
}
@@ -173,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;
@@ -209,15 +239,43 @@ namespace StarfaceOutlookSync.Services
if (array.Count == 0) break;
// Ersten Kontakt als Debug-Info loggen
if (firstPage && array.Count > 0)
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
if (firstPage)
{
OnDebug?.Invoke($"Starface API Rohdaten (1. Kontakt):\n{array[0].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;
}
foreach (var item in array)
contacts.Add(MapFromStarface(item));
// 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;
page++;
@@ -226,32 +284,130 @@ 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);
// 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 = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
if (!resp.IsSuccessStatusCode) return null;
var body = sfContact.ToString();
OnDebug?.Invoke($"POST /contacts{query} Body:\n{body}");
var created = JObject.Parse(await resp.Content.ReadAsStringAsync());
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"POST /contacts fehlgeschlagen: {(int)resp.StatusCode} {resp.StatusCode}\n{respBody}");
return null;
}
var created = JObject.Parse(respBody);
return MapFromStarface(created);
}
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;
// Tag beibehalten
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray { new JObject { ["id"] = book.TagId } };
}
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(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);
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)
@@ -265,7 +421,10 @@ namespace StarfaceOutlookSync.Services
var contact = new UnifiedContact();
contact.StarfaceId = item["id"]?.ToString() ?? "";
var attrs = new Dictionary<string, string>();
// Attribute per "name"-Feld mappen (zuverlaessiger als displayKey,
// weil viele Felder USER_DEFINED als displayKey haben)
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byDisplayKey = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var blocks = item["blocks"] as JArray;
if (blocks != null)
{
@@ -275,79 +434,123 @@ namespace StarfaceOutlookSync.Services
if (blockAttrs == null) continue;
foreach (var attr in blockAttrs)
{
var key = attr["displayKey"]?.ToString() ?? "";
var name = attr["name"]?.ToString() ?? "";
var displayKey = attr["displayKey"]?.ToString() ?? "";
var val = attr["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(val))
attrs[key] = val;
{
if (!string.IsNullOrEmpty(name))
byName[name] = val;
// displayKey nur als Fallback (viele sind USER_DEFINED)
if (!string.IsNullOrEmpty(displayKey) && displayKey != "USER_DEFINED")
byDisplayKey[displayKey] = val;
}
}
}
}
contact.FirstName = attrs.GetValueOrDefault("NAME", "");
contact.LastName = attrs.GetValueOrDefault("SURNAME", "");
contact.Company = attrs.GetValueOrDefault("COMPANY", "");
contact.JobTitle = attrs.GetValueOrDefault("JOB_TITLE", "");
contact.Email = attrs.GetValueOrDefault("EMAIL", "");
contact.PhoneWork = attrs.GetValueOrDefault("OFFICE_PHONE_NUMBER", "");
contact.PhoneMobile = attrs.GetValueOrDefault("MOBILE_PHONE_NUMBER", "");
contact.PhoneHome = attrs.GetValueOrDefault("PRIVATE_PHONE_NUMBER", "");
contact.Fax = attrs.GetValueOrDefault("FAX_NUMBER", "");
contact.Street = attrs.GetValueOrDefault("STREET", "");
contact.City = attrs.GetValueOrDefault("CITY", "");
contact.PostalCode = attrs.GetValueOrDefault("POSTAL_CODE", "");
contact.State = attrs.GetValueOrDefault("STATE", "");
contact.Country = attrs.GetValueOrDefault("COUNTRY", "");
contact.Website = attrs.GetValueOrDefault("URL", "");
contact.Notes = attrs.GetValueOrDefault("NOTE", "");
contact.Salutation = attrs.GetValueOrDefault("SALUTATION", "");
contact.Title = attrs.GetValueOrDefault("TITLE", "");
contact.Birthday = attrs.GetValueOrDefault("BIRTHDAY", "");
// Primaer nach name-Feld mappen, Fallback auf displayKey
string Get(string name, string displayKey = null)
{
if (byName.TryGetValue(name, out var v)) return v;
if (displayKey != null && byDisplayKey.TryGetValue(displayKey, out v)) return v;
return "";
}
contact.FirstName = Get("firstname", "NAME");
contact.LastName = Get("familyname", "SURNAME");
contact.Company = Get("company", "COMPANY");
contact.JobTitle = Get("jobtitle", "JOB_TITLE");
contact.Email = Get("e-mail", "EMAIL");
contact.PhoneWork = Get("phone", "PHONE_NUMBER");
contact.PhoneMobile = Get("mobile", "MOBILE_PHONE_NUMBER");
contact.PhoneHome = Get("homephone", "PRIVATE_PHONE_NUMBER");
contact.Fax = Get("fax", "FAX_NUMBER");
contact.Street = Get("street", "STREET");
contact.City = Get("city", "CITY");
contact.PostalCode = Get("postcode", "POSTAL_CODE");
contact.State = Get("state", "STATE");
contact.Country = Get("country", "COUNTRY");
contact.Website = Get("url", "URL");
contact.Notes = Get("comment", "NOTE");
contact.Salutation = Get("salutation", "SALUTATION");
contact.Title = Get("title", "TITLE");
contact.Birthday = Get("birthday", "BIRTHDAY");
return contact;
}
private JObject MapToStarface(UnifiedContact contact)
{
var attrs = new JArray();
void AddAttr(string displayKey, string name, string value)
JArray MakeAttrs(params (string displayKey, string name, string value)[] fields)
{
if (!string.IsNullOrEmpty(value))
attrs.Add(new JObject { ["displayKey"] = displayKey, ["name"] = name, ["value"] = value });
var arr = new JArray();
foreach (var (dk, n, v) in fields)
{
if (!string.IsNullOrEmpty(v))
arr.Add(new JObject { ["displayKey"] = dk, ["name"] = n, ["value"] = v });
}
return arr;
}
AddAttr("NAME", "firstName", contact.FirstName);
AddAttr("SURNAME", "lastName", contact.LastName);
AddAttr("COMPANY", "company", contact.Company);
AddAttr("JOB_TITLE", "jobTitle", contact.JobTitle);
AddAttr("EMAIL", "email", contact.Email);
AddAttr("OFFICE_PHONE_NUMBER", "businessPhone", contact.PhoneWork);
AddAttr("MOBILE_PHONE_NUMBER", "mobilePhone", contact.PhoneMobile);
AddAttr("PRIVATE_PHONE_NUMBER", "homePhone", contact.PhoneHome);
AddAttr("FAX_NUMBER", "fax", contact.Fax);
AddAttr("STREET", "street", contact.Street);
AddAttr("CITY", "city", contact.City);
AddAttr("POSTAL_CODE", "postalCode", contact.PostalCode);
AddAttr("STATE", "state", contact.State);
AddAttr("COUNTRY", "country", contact.Country);
AddAttr("URL", "website", contact.Website);
AddAttr("NOTE", "notes", contact.Notes);
AddAttr("SALUTATION", "salutation", contact.Salutation);
AddAttr("TITLE", "title", contact.Title);
AddAttr("BIRTHDAY", "birthday", contact.Birthday);
// Block-Struktur wie von der Starface erwartet
var contactBlock = MakeAttrs(
("NAME", "firstname", contact.FirstName),
("SURNAME", "familyname", contact.LastName),
("COMPANY", "company", contact.Company)
);
var addressBlock = MakeAttrs(
("USER_DEFINED", "street", contact.Street),
("POSTAL_CODE", "postcode", contact.PostalCode),
("USER_DEFINED", "city", contact.City),
("USER_DEFINED", "state", contact.State),
("USER_DEFINED", "country", contact.Country)
);
var phoneBlock = MakeAttrs(
("PHONE_NUMBER", "phone", contact.PhoneWork),
("PRIVATE_PHONE_NUMBER", "homephone", contact.PhoneHome),
("MOBILE_PHONE_NUMBER", "mobile", contact.PhoneMobile),
("FAX_NUMBER", "fax", contact.Fax)
);
var emailBlock = MakeAttrs(
("EMAIL", "e-mail", contact.Email),
("URL", "url", contact.Website),
("USER_DEFINED", "comment", contact.Notes)
);
var blocks = new JArray();
blocks.Add(new JObject
{
["name"] = "contact",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_contact",
["attributes"] = contactBlock
});
blocks.Add(new JObject
{
["name"] = "address",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_address",
["attributes"] = addressBlock
});
blocks.Add(new JObject
{
["name"] = "telephone",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_telephone",
["attributes"] = phoneBlock
});
blocks.Add(new JObject
{
["name"] = "email",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_email",
["attributes"] = emailBlock
});
return new JObject
{
["id"] = contact.StarfaceId ?? "",
["blocks"] = new JArray
{
new JObject
{
["name"] = "contact",
["resourceKey"] = "contact",
["attributes"] = attrs
}
}
["blocks"] = blocks
};
}
+527 -109
View File
@@ -15,30 +15,83 @@ namespace StarfaceOutlookSync.Services
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>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// 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>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
// Erst E-Mail-Match
if (!string.IsNullOrEmpty(contact.Email))
{
var byEmail = candidates.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.Email) &&
c.Email.Equals(contact.Email, StringComparison.OrdinalIgnoreCase));
if (byEmail != null) return byEmail;
}
if (candidates == null || candidates.Count == 0) return null;
// Dann Name-Match
if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
foreach (var c in candidates)
{
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;
if (IsMatch(contact, c))
return c;
}
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)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
@@ -49,7 +102,6 @@ namespace StarfaceOutlookSync.Services
try
{
// Starface verbinden
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
{
@@ -62,10 +114,6 @@ namespace StarfaceOutlookSync.Services
return result;
}
var mappings = _profileManager.GetMappings(profile.Id);
var mappingByOutlook = mappings.ToDictionary(m => m.OutlookEntryId, m => m);
var mappingByStarface = mappings.ToDictionary(m => m.StarfaceId, m => m);
// Kontakte laden
Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
@@ -75,144 +123,514 @@ namespace StarfaceOutlookSync.Services
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Outlook -> Starface
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.OutlookToStarface)
{
Log("Synchronisiere Outlook -> Starface...");
foreach (var oc in outlookContacts)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(oc.OutlookEntryId))
mappingByOutlook.TryGetValue(oc.OutlookEntryId, out existing);
// Bestehende Mappings laden
var mappings = _profileManager.GetMappings(profile.Id);
if (existing != null)
// Sets fuer schnellen Lookup
var mappingByOutlook = new Dictionary<string, SyncMapping>();
var mappingByStarface = new Dictionary<string, SyncMapping>();
foreach (var m in mappings)
{
if (!string.IsNullOrEmpty(m.OutlookEntryId))
mappingByOutlook[m.OutlookEntryId] = m;
if (!string.IsNullOrEmpty(m.StarfaceId))
mappingByStarface[m.StarfaceId] = m;
}
// Tracking: welche Kontakte wurden bereits verarbeitet
var processedStarfaceIds = new HashSet<string>();
var processedOutlookIds = new HashSet<string>();
var newMappings = new List<SyncMapping>();
// ============================================
// Phase 1: Bestehende Mappings abgleichen
// ============================================
Log("Gleiche bestehende Zuordnungen ab...");
foreach (var mapping in mappings.ToList())
{
var oc = outlookContacts.FirstOrDefault(c => c.OutlookEntryId == mapping.OutlookEntryId);
var sc = starfaceContacts.FirstOrDefault(c => c.StarfaceId == mapping.StarfaceId);
if (oc != null) processedOutlookIds.Add(oc.OutlookEntryId);
if (sc != null) processedStarfaceIds.Add(sc.StarfaceId);
if (oc == null && sc == null)
{
// Beide Seiten geloescht -> Mapping entfernen
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))
{
var hash = oc.GetHash();
if (hash != existing.LastSyncHash)
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))
{
if (await starface.UpdateContactAsync(existing.StarfaceId, oc, profile.StarfaceAddressBook))
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;
}
if (oc != null && sc != null)
{
// 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();
// 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))
{
// Outlook hat sich geaendert -> Starface updaten
var updated = await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
}
}
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{
// Starface hat sich geaendert -> Outlook updaten
var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null)
{
SetBaseline(mapping, updated, sc);
result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
}
}
else if (olChanged && sfChanged)
{
// Beide Seiten geaendert.
if (profile.SyncDirection == SyncDirection.Both
&& mapping.LastOutlook != null && mapping.LastStarface != null)
{
// 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)
{
if (updatedOl != null)
{
existing.LastSyncHash = hash;
result.Updated++;
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++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
}
}
else
{
var match = FindMatch(oc, starfaceContacts);
if (match != null && !string.IsNullOrEmpty(match.StarfaceId))
var updated = _outlookService.UpdateContact(mapping.OutlookEntryId, sc);
if (updated != null)
{
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook))
SetBaseline(mapping, updated, sc);
result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
}
}
}
}
newMappings.Add(mapping);
}
// ============================================
// Phase 2: Neue Outlook-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
if (unmappedOutlook.Count > 0)
Log($"Neue Outlook-Kontakte: {unmappedOutlook.Count}");
// Starface-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
foreach (var oc in unmappedOutlook)
{
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook);
if (updated != null)
{
newMappings.Add(new SyncMapping
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Updated++;
}
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastOutlook = oc,
LastStarface = updated,
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = updated.GetHash()
});
processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match);
result.Updated++;
Log($" Verknuepft (OL->SF): {oc.DisplayName}");
}
}
else
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastOutlook = oc,
LastStarface = created,
LastOutlookHash = oc.GetHash(),
LastStarfaceHash = created.GetHash()
});
result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}");
}
else
{
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Created++;
}
Log($" FEHLER: Kontakt konnte nicht erstellt werden: {oc.DisplayName}");
result.Errors++;
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{oc.DisplayName}: {ex.Message}");
result.ErrorMessages.Add($"OL->SF {oc.DisplayName}: {ex.Message}");
}
}
}
// Starface -> Outlook
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.StarfaceToOutlook)
// ============================================
// Phase 3: Neue Starface-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
Log("Synchronisiere Starface -> Outlook...");
foreach (var sc in starfaceContacts)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
if (unmappedStarface.Count > 0)
Log($"Neue Starface-Kontakte: {unmappedStarface.Count}");
// Outlook-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
foreach (var sc in unmappedStarface)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(sc.StarfaceId))
mappingByStarface.TryGetValue(sc.StarfaceId, out existing);
if (existing != null)
// Duplikat-Check: existiert der Kontakt schon in Outlook?
var match = FindMatch(sc, unmappedOutlook);
if (match != null)
{
var hash = sc.GetHash();
if (hash != existing.LastSyncHash)
// Existiert schon -> verknuepfen und updaten
var updated = _outlookService.UpdateContact(match.OutlookEntryId, sc);
if (updated != null)
{
if (_outlookService.UpdateContact(existing.OutlookEntryId, sc))
newMappings.Add(new SyncMapping
{
existing.LastSyncHash = hash;
result.Updated++;
}
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastStarface = sc,
LastOutlook = updated,
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = updated.GetHash()
});
processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match);
result.Updated++;
Log($" Verknuepft (SF->OL): {sc.DisplayName}");
}
}
else
{
var match = FindMatch(sc, outlookContacts);
if (match != null && !string.IsNullOrEmpty(match.OutlookEntryId))
// Neu -> in Outlook erstellen
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
if (_outlookService.UpdateContact(match.OutlookEntryId, sc))
newMappings.Add(new SyncMapping
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Updated++;
}
}
else
{
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Created++;
}
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastStarface = sc,
LastOutlook = created,
LastStarfaceHash = sc.GetHash(),
LastOutlookHash = created.GetHash()
});
result.Created++;
Log($" Erstellt (SF->OL): {sc.DisplayName}");
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{sc.DisplayName}: {ex.Message}");
result.ErrorMessages.Add($"SF->OL {sc.DisplayName}: {ex.Message}");
}
}
}
// ============================================
// 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);
_profileManager.SaveMappings(profile.Id, mappings);
await starface.LogoutAsync();
Log("Synchronisation abgeschlossen!");
Log($"Fertig: {result.Created} erstellt, {result.Updated} aktualisiert, {result.Errors} Fehler");
}
}
catch (Exception ex)
@@ -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>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product>
<Version>0.0.0.9</Version>
<AssemblyVersion>0.0.0.9</AssemblyVersion>
<FileVersion>0.0.0.9</FileVersion>
<Version>0.0.0.29</Version>
<AssemblyVersion>0.0.0.29</AssemblyVersion>
<FileVersion>0.0.0.29</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.9",
Text = "Version 0.0.0.29",
Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
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);
}
}
}
}
@@ -0,0 +1,90 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
/// <summary>Zeigt das Sync-Protokoll an (read-only) mit Aktualisieren/Leeren.</summary>
public class LogViewerForm : Form
{
private TextBox _txt;
private Button _btnClose;
private Panel _panel;
public LogViewerForm()
{
Text = "Protokoll";
Size = new Size(720, 500);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
_txt = new TextBox
{
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both,
WordWrap = false,
Dock = DockStyle.Fill,
BackColor = Color.White,
Font = new Font("Consolas", 9)
};
_panel = new Panel { Dock = DockStyle.Bottom, Height = 44 };
var btnRefresh = new Button { Text = "Aktualisieren", Left = 10, Top = 8, Width = 100, Height = 28 };
btnRefresh.Click += (s, e) => LoadLog();
var btnClear = new Button { Text = "Leeren", Left = 118, Top = 8, Width = 80, Height = 28 };
btnClear.Click += (s, e) =>
{
if (MessageBox.Show("Protokoll wirklich leeren?", "Protokoll",
MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
Logger.Clear();
LoadLog();
}
};
var btnFolder = new Button { Text = "Ordner oeffnen", Left = 206, Top = 8, Width = 120, Height = 28 };
btnFolder.Click += (s, e) => OpenFolder();
_btnClose = new Button
{
Text = "Schliessen", Top = 8, Width = 100, Height = 28,
Anchor = AnchorStyles.Top | AnchorStyles.Right
};
_btnClose.Click += (s, e) => Close();
_panel.Controls.AddRange(new Control[] { btnRefresh, btnClear, btnFolder, _btnClose });
Controls.Add(_txt);
Controls.Add(_panel);
Load += (s, e) =>
{
_btnClose.Left = _panel.ClientSize.Width - _btnClose.Width - 10;
LoadLog();
};
}
private void LoadLog()
{
_txt.Text = Logger.ReadAll();
_txt.SelectionStart = _txt.TextLength;
_txt.ScrollToCaret();
}
private void OpenFolder()
{
try
{
var dir = Path.GetDirectoryName(Logger.LogFilePath);
Process.Start(new ProcessStartInfo { FileName = dir, UseShellExecute = true });
}
catch { }
}
}
}
+231 -19
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Forms;
@@ -15,13 +16,17 @@ namespace StarfaceOutlookSync.UI
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly SyncEngine _syncEngine = new SyncEngine();
private readonly ConflictNotifier _conflictNotifier = new ConflictNotifier();
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnLog, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
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()
{
@@ -30,14 +35,31 @@ namespace StarfaceOutlookSync.UI
SetupAutoSync();
RefreshProfileList();
// Minimiert starten falls in Einstellungen aktiviert
// Einstellungen laden und anwenden
var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
if (settings.StartMinimized)
{
WindowState = FormWindowState.Minimized;
ShowInTaskbar = 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)
@@ -58,10 +80,11 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(620, 450);
MinimumSize = new Size(500, 350);
Size = new Size(830, 450);
MinimumSize = new Size(830, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
// Profil-Liste
_profileList = new ListView
@@ -97,16 +120,22 @@ namespace StarfaceOutlookSync.UI
_btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 };
_btnDelete.Click += (s, e) => DeleteProfile();
_btnSync = new Button { Text = "Jetzt synchronisieren", Width = 150, Height = 30 };
_btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnSettings = new Button { Text = "Einstellungen", Width = 100, Height = 30 };
_btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 };
_btnReset.Click += (s, e) => ResetSync();
_btnSettings = new Button { Text = "Einstellungen", Width = 95, Height = 30 };
_btnSettings.Click += (s, e) => ShowSettings();
_btnLog = new Button { Text = "Protokoll", Width = 80, Height = 30 };
_btnLog.Click += (s, e) => ShowLog();
_btnInfo = new Button { Text = "Info", Width = 50, Height = 30 };
_btnInfo.Click += (s, e) => ShowAbout();
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo });
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnLog, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
@@ -121,10 +150,26 @@ namespace StarfaceOutlookSync.UI
private void SetupTrayIcon()
{
_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("-");
// Schnell-Sync fuer jedes Profil
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled))
{
@@ -138,17 +183,8 @@ namespace StarfaceOutlookSync.UI
if (profiles.Any(p => p.Enabled))
_trayMenu.Items.Add("-");
_trayMenu.Items.Add("Ueber", null, (s, e) => ShowAbout());
_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()
@@ -216,7 +252,7 @@ namespace StarfaceOutlookSync.UI
}
// Tray-Menu aktualisieren
SetupTrayIcon();
UpdateTrayMenu();
}
private void NewProfile()
@@ -255,6 +291,36 @@ namespace StarfaceOutlookSync.UI
}
}
private void ResetSync()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync Reset",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
var msg = $"Sync-Zuordnungen fuer '{profile.Name}' zuruecksetzen?\n\n" +
"Alle Kontakt-Verknuepfungen werden geloescht.\n" +
"Beim naechsten Sync werden die Kontakte neu abgeglichen.\n" +
"Es werden keine Kontakte geloescht.";
if (MessageBox.Show(msg, "Sync Reset",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
{
_profileManager.SaveMappings(profile.Id, new List<SyncMapping>());
// LastSync auch zuruecksetzen
profile.LastSync = "";
_profileManager.UpdateProfile(profile);
RefreshProfileList();
MessageBox.Show("Sync-Zuordnungen wurden zurueckgesetzt.",
"Sync Reset", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private Task SyncSelectedProfile()
{
if (_profileList.SelectedItems.Count == 0)
@@ -278,8 +344,34 @@ namespace StarfaceOutlookSync.UI
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;
var sharedDir = UserSettings.Load().SharedDirectory;
try
{
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
crossLock = await AcquireCrossClientLock(sharedDir);
if (crossLock == null)
{
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
Logger.Log($"Sync '{profile.Name}' uebersprungen: anderer Arbeitsplatz synct gerade.");
return;
}
Logger.Log($"Sync gestartet: '{profile.Name}' (Richtung: {profile.SyncDirection})");
// Konflikt-Hinweise von ANDEREN Arbeitsplaetzen anzeigen, BEVOR der
// Sync den eigenen Stand auf den Gewinner-Wert aktualisiert (sonst
// koennte man nicht mehr erkennen, ob man betroffen war).
ShowRemoteConflictNotices(sharedDir);
SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
@@ -288,10 +380,32 @@ namespace StarfaceOutlookSync.UI
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert";
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,
result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info);
Logger.Log($"Sync fertig: {msg}");
foreach (var em in result.ErrorMessages)
Logger.Log($" Fehler: {em}");
// 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);
foreach (var c in result.Conflicts)
Logger.Log($" KONFLIKT: {c}");
// Andere Arbeitsplaetze ueber das gemeinsame Verzeichnis informieren.
_conflictNotifier.Write(sharedDir, result.Conflicts);
}
SetStatus(msg);
}
catch (Exception ex)
@@ -299,7 +413,97 @@ namespace StarfaceOutlookSync.UI
_trayIcon.ShowBalloonTip(3000, "Starface Sync Fehler",
ex.Message, ToolTipIcon.Error);
SetStatus($"Fehler: {ex.Message}");
Logger.Log($"Sync FEHLER '{profile.Name}': {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(string dir)
{
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);
}
}
/// <summary>
/// Zeigt Konflikt-Hinweise an, die andere Arbeitsplaetze ueber das
/// gemeinsame Verzeichnis hinterlassen haben und Kontakte betreffen, die
/// auch dieser Client gemappt hat.
/// </summary>
private void ShowRemoteConflictNotices(string sharedDir)
{
if (string.IsNullOrWhiteSpace(sharedDir)) return;
try
{
var pending = _conflictNotifier.GetPending(sharedDir, MyContactsByStarfaceId());
if (pending.Count == 0) return;
foreach (var p in pending)
Logger.Log($" KONFLIKT (anderer Arbeitsplatz): {p}");
var detail = string.Join("\n", pending.Take(5).Select(p => p.ToString()));
if (pending.Count > 5)
detail += $"\n... und {pending.Count - 5} weitere";
_trayIcon.ShowBalloonTip(10000,
$"Konflikt an anderem Arbeitsplatz ({pending.Count})", detail, ToolTipIcon.Warning);
}
catch { }
}
/// <summary>
/// Eigener Kontaktstand je StarfaceId (ueber alle Profile), aus den
/// Mapping-Snapshots. Dient dazu, bei Konflikt-Notizen zu pruefen, ob der
/// eigene Feldwert ueberhaupt vom uebernommenen Wert abweicht.
/// </summary>
private Dictionary<string, UnifiedContact> MyContactsByStarfaceId()
{
var map = new Dictionary<string, UnifiedContact>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var p in _profileManager.GetProfiles())
foreach (var m in _profileManager.GetMappings(p.Id))
if (!string.IsNullOrEmpty(m.StarfaceId) && !map.ContainsKey(m.StarfaceId))
map[m.StarfaceId] = m.LastOutlook;
}
catch { }
return map;
}
private void SetStatus(string text)
@@ -326,6 +530,14 @@ namespace StarfaceOutlookSync.UI
}
}
private void ShowLog()
{
using (var log = new LogViewerForm())
{
log.ShowDialog(this);
}
}
private void ExitApplication()
{
_autoSyncTimer?.Stop();
@@ -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 };
@@ -333,9 +361,23 @@ namespace StarfaceOutlookSync.UI
};
if (_isNew)
{
_pm.AddProfile(profile);
}
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);
}
DialogResult = DialogResult.OK;
Close();
+78 -5
View File
@@ -6,7 +6,9 @@ namespace StarfaceOutlookSync.UI
{
public class SettingsForm : Form
{
private CheckBox _chkStartMinimized;
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private TextBox _txtSharedDir;
private Button _btnBrowseShared;
private Button _btnSave, _btnCancel;
private readonly UserSettings _settings;
@@ -19,7 +21,7 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Einstellungen";
Size = new Size(350, 180);
Size = new Size(380, 250);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
@@ -33,27 +35,98 @@ namespace StarfaceOutlookSync.UI
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
{
Text = "Speichern", Left = 80, Top = 100, Width = 85, Height = 28,
Text = "Speichern", Left = 95, Top = 240, Width = 85, Height = 28,
DialogResult = DialogResult.None
};
_btnSave.Click += (s, e) => Save();
_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
};
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;
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()
{
_settings.StartMinimized = _chkStartMinimized.Checked;
_settings.SyncOnStart = _chkSyncOnStart.Checked;
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
_settings.SharedDirectory = _txtSharedDir.Text.Trim();
_settings.Save();
DialogResult = DialogResult.OK;
Close();
@@ -41,7 +41,8 @@ namespace StarfaceOutlookSync.UI
_progressBar = new ProgressBar
{
Left = 12, Top = 38, Width = 460, Height = 22,
Style = ProgressBarStyle.Marquee
Style = ProgressBarStyle.Blocks,
Value = 0
};
_txtLog = new TextBox