Compare commits

..
70 Commits
Author SHA1 Message Date
duffyduck da20d341d7 Release v0.0.3.1 2026-08-17 13:19:07 +02:00
duffyduckandClaude Opus 4.8 ca05af75e3 Add "Dubletten zusammenfuehren" (merge duplicates) per profile
Neuer Button "Dubletten" im Hauptfenster fuehrt doppelte Kontakte fuer das
ausgewaehlte Profil zusammen - je Seite (Telefonanlage + Outlook) getrennt.

- Matching in ContactMatcher extrahiert (Sync UND Dedupe nutzen exakt dieselbe
  Erkennung).
- ContactMerger.FillEmptyInto / FilledFieldCount: leere Felder des Gewinners
  auffuellen (kein Datenverlust), Gewinner-Wahl nach Zuordnung/Vollstaendigkeit.
- DedupeService: Analyse (Vorschau) + Ausfuehrung (Merge, Loeschen der
  Dubletten, Bereinigen verwaister Zuordnungen).
- SyncCoordinator.RunExclusiveAsync: Dedupe laeuft unter demselben Guard +
  Lock-Datei wie ein Sync (nie gleichzeitig; kein Konflikt zwischen Clients).
- DedupeForm: Vorschau mit optionaler Detailliste, Bestaetigung, Live-Log;
  danach Hinweis, einmal zu synchronisieren.
- MainForm: Button "Dubletten" (Profil-Auswahl noetig), Fenster verbreitert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 13:11:16 +02:00
duffyduckandClaude Opus 4.8 9767299edd Fix duplicate company/service contacts: robust phone matching
Firmen-/Service-Eintraege (nur Nummer + Firma, kein Name/E-Mail) wurden bei
jedem Sync neu angelegt statt verknuepft, weil das Matching Rufnummern
feldgenau und formatabhaengig verglich. Bei unterschiedlich formatierten
Nummern (+49 vs 0) und wechselnden Feldern (geschaeftlich/privat) schlug der
Abgleich fehl -> Vervielfachung.

- NormalizePhone vereinheitlicht jetzt +49/0049 -> 0 (SyncEngine + ContactMerger).
- Telefon-Abgleich feldUEBERGREIFEND (jede Nummer gegen jede) via SharedPhone.
- "Nummer + Firma"-Treffer nur noch fuer Eintraege OHNE Personennamen, damit
  Kollegen mit gemeinsamer Zentrale-Nummer nicht verschmolzen werden.
- Zusaetzlich: reine Nummern-Eintraege (kein Name/Firma/E-Mail) matchen ueber
  eine gemeinsame Rufnummer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-17 12:59:54 +02:00
duffyduckandClaude Opus 5 6ecd5d7fa3 Fonaria als zweite Anlage neben Starface
Die SyncEngine kennt jetzt nur noch IContactBackend. Was dahinter steckt,
entscheidet das Profil — der Ablauf mit getrennten Baselines, Wiederzuordnung
und Konfliktaufloesung bleibt unangetastet. In ihm steckt die Erfahrung, die
man nicht zweimal machen will.

FonariaApiClient spricht deren REST-API. Der Kern ist das Feld `verwaltet`,
das jeder Schreibvorgang mitschickt: Fonaria haelt den Kontakt als
vollstaendige vCard, dieses Sync-Modell bildet davon nur einen Ausschnitt ab.
Ohne diese Liste haette ein aus Outlook gespiegelter Kontakt nach dem ersten
Abgleich kein Bild, keine Kategorien und keinen Jahrestag mehr — nicht weil
jemand sie geloescht haette, sondern weil wir sie nicht kannten.

Zwei Verluste lagen darunter und sind gefunden worden, weil der Client gegen
eine echte Anlage lief:

- Die Abteilung steckt im zweiten Teil von ORG. Wer nur die Firma schickt,
  loescht sie mit.
- Neben der geschaeftlichen Anschrift kann eine private stehen. Das
  Sync-Modell traegt nur eine.

Beides wird jetzt aus dem gelesenen Stand unveraendert zurueckgeschrieben.

Umbenannt: StarfaceConnection -> SystemConnection, StarfaceAddressBook ->
RemoteAddressBook. Die JSON-Namen bleiben ueber JsonProperty erhalten, sonst
verloeren bestehende Profile ihre Verbindungsdaten. SyncMapping.StarfaceId
behaelt seinen Namen aus demselben Grund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:10:43 +02:00
duffyduck 88635c04f8 Release v0.0.0.30 2026-06-08 13:53:34 +02:00
duffyduckandClaude Opus 4.8 6b025a24f0 Add autostart management (per-user / all-users) in settings
Setup setzt den Autostart weiterhin fuer alle Benutzer (HKLM); die
Task-Beschreibung im Installer stellt das jetzt klar.

In den Einstellungen neuer Abschnitt "Autostart":
- "Nur fuer diesen Benutzer" -> HKCU\...\Run
- "Fuer alle Benutzer" -> HKLM\...\Run, nur mit Admin-Rechten aenderbar
  (so laesst sich der vom Setup gesetzte All-User-Autostart auch entfernen).

Neuer AutostartManager kapselt Lesen/Setzen/Entfernen beider Run-Eintraege
und die Admin-Pruefung. Single-Instance-Mutex verhindert weiterhin einen
Doppelstart, falls beide Eintraege gesetzt sind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:52:34 +02:00
duffyduckandClaude Opus 4.8 641267081a Add log auto-clear setting (retention in days, 0 = off)
Neue Einstellung "Protokoll auto-leeren - Eintraege aelter als (Tage)".
0 = aus (alle Eintraege bleiben), >0 entfernt aeltere Eintraege.

- UserSettings.LogRetentionDays (Standard 0).
- Logger.PruneOlderThan(days): parst den Zeitstempel-Prefix je Zeile und
  entfernt zu alte; Zeilen ohne Zeitstempel bleiben erhalten.
- Ausgefuehrt beim Start, vor jedem Sync (Coordinator), beim Oeffnen des
  Protokolls und beim Speichern der Einstellungen.
- SettingsForm: NumericUpDown (0-3650).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:47:10 +02:00
duffyduckandClaude Opus 4.8 0336530742 Unify manual and automatic sync via SyncCoordinator
Der manuelle "Synchronisieren"-Button (SyncProgressForm) lief ueber eine eigene
Engine und umging die clientuebergreifende Lock-Datei, den lokalen Guard und
einen Teil des Protokolls. Jetzt teilen sich beide Pfade einen SyncCoordinator.

- Neuer SyncCoordinator kapselt: prozessweiten Re-Entrancy-Guard (statisch, so
  koennen manueller + automatischer Sync nicht mehr gleichzeitig laufen),
  Lock-Datei (mit Warte-Status), Konflikt-Notizen anderer Arbeitsplaetze,
  Protokoll (Start/Ergebnis/Aenderungen/Konflikte/Fehler) und das Verteilen
  eigener Konflikt-Notizen. runEngine wird vom Aufrufer uebergeben, damit das
  Threading pro Pfad erhalten bleibt (UI-Thread vs. Task.Run).
- MainForm.RunSync und SyncProgressForm.RunSync nutzen den Coordinator; UI
  (Tray-Meldung vs. Fenster) bleibt jeweils beim Aufrufer.
- Lock/Notiz/Guard-Logik aus MainForm entfernt (jetzt zentral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:43:03 +02:00
duffyduckandClaude Opus 4.8 75c0c1126e Log what changed; add notification on/off settings
Protokoll:
- SyncResult.Changes erfasst jede tatsaechliche Aenderung (erstellt/aktualisiert/
  geloescht/verknuepft/zusammengefuehrt je Kontakt) per Action()-Helfer.
- Beide Sync-Pfade (Auto/Tray via RunSync UND manueller Sync via
  SyncProgressForm) schreiben Start, Ergebnis, Aenderungen, Konflikte und Fehler
  ins persistente Protokoll.

Benachrichtigungen:
- UserSettings: NotificationsEnabled (allgemein) + NotifyWarningsErrors
  (Konflikte/Fehler), beide in der Einstellungen-Maske als Haken.
- MainForm.Balloon() zeigt Tray-Meldungen nur, wenn der passende Haken aktiv
  ist; Zusammenfassung gilt als Warnung, wenn Fehler/Konflikte auftraten.
- Protokoll wird unabhaengig von den Benachrichtigungs-Einstellungen geschrieben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:35:55 +02:00
duffyduck 96c34e542f Release v0.0.0.29 2026-06-08 13:18:35 +02:00
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.8 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 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
duffyduckandClaude Opus 4.6 1be0a94b51 Fetch full contact details from Starface instead of summary
The contacts list endpoint only returns summaryValues/phoneNumbers.
Now fetch each contact individually via GET /contacts/{id} to get
all fields (blocks/attributes). Also log the detail JSON structure
so we can verify the field mapping is correct.

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:08:50 +02:00
duffyduck bf0be12a5e Release v0.0.0.7 2026-04-03 12:02:37 +02:00
duffyduckandClaude Opus 4.6 59d4c094a9 Replace Outlook Interop NuGet with dynamic COM binding
The NuGet package Microsoft.Office.Interop.Outlook v15 tried to
load Office v15 PIA which fails on Office 2016/2019/2024 (v16).

Switch to dynamic COM (late binding) which works with ANY Outlook
Classic version without needing a specific Interop DLL. All Outlook
properties and methods are accessed via dynamic dispatch.

Also use 1-based COM collection indexing instead of foreach to
avoid COM enumerator issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:01:49 +02:00
29 changed files with 4112 additions and 397 deletions
+168
View File
@@ -0,0 +1,168 @@
# 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]
### Hinzugefuegt
- **Dubletten zusammenfuehren (Button "Dubletten").** Fuehrt doppelte Kontakte
fuer das ausgewaehlte Profil zusammen - getrennt je Seite (Telefonanlage und
Outlook). Zuerst wird analysiert und eine Vorschau (optional mit Detailliste)
gezeigt; nach Bestaetigung bleibt je Gruppe ein "Gewinner", dessen leere
Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die uebrigen
werden geloescht. Verwaiste Zuordnungen werden bereinigt, sodass der naechste
bidirektionale Sync die Gewinner sauber 1:1 verbindet. Laeuft unter derselben
Sperre wie ein Sync (kein gleichzeitiger Lauf), nutzt dieselbe Kontakt-
Erkennung wie der Sync und wird vollstaendig protokolliert.
### Behoben
- **Dubletten bei Firmen-/Service-Eintraegen (nur Nummer + Firma).** Solche
Eintraege wurden bei jedem Sync neu angelegt statt verknuepft, weil der
Nummern-Abgleich feldgenau (geschaeftlich↔geschaeftlich) und formatabhaengig
war. Jetzt werden Rufnummern feldUEBERGREIFEND und formatunabhaengig
verglichen (`+49`/`0049` == `0`), sodass diese Eintraege verknuepft werden.
Der "Nummer + Firma"-Abgleich greift bewusst nur bei Eintraegen OHNE
Personennamen, damit keine zwei Kollegen mit gleicher Zentrale-Nummer
verschmolzen werden. (Bereits entstandene Dubletten muessen einmalig manuell
bereinigt werden.)
- **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
- **Autostart-Verwaltung in den Einstellungen.** Neuer Abschnitt "Autostart" mit
zwei Optionen: "Nur fuer diesen Benutzer" (HKCU) und "Fuer alle Benutzer"
(HKLM). Die Option fuer alle Benutzer ist nur mit Admin-Rechten aenderbar -
darueber laesst sich auch der vom Setup gesetzte Autostart fuer alle wieder
entfernen. Das Setup setzt den Autostart weiterhin fuer alle Benutzer (HKLM),
der Hinweis im Installer wurde entsprechend klargestellt.
- **Protokoll automatisch leeren (Einstellung).** Neuer Wert "Protokoll
auto-leeren - Eintraege aelter als (Tage)". Bei 0 (Standard) bleibt alles
erhalten; bei >0 werden aeltere Eintraege automatisch entfernt (beim Start,
vor jedem Sync, beim Oeffnen des Protokolls und beim Speichern der
Einstellungen).
- **Protokoll zeigt jetzt, WAS geaendert wurde.** Pro Sync werden die einzelnen
Aktionen (erstellt / aktualisiert / geloescht / verknuepft / zusammengefuehrt je
Kontakt) ins Protokoll geschrieben - sowohl beim manuellen Sync (Fenster) als
auch bei Auto-/Tray-Sync.
- **Benachrichtigungen schaltbar (Einstellungen).** Zwei unabhaengige Haken:
"Tray-Benachrichtigungen (allgemein)" fuer Info-Meldungen (Sync laeuft/fertig)
und "Benachrichtigungen bei Warnungen/Fehlern" fuer Konflikte und Fehler. So
kann man z.B. nur noch bei Konflikten/Fehlern benachrichtigt werden - oder gar
nicht. Das Protokoll wird unabhaengig davon immer geschrieben.
- **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
- **Manueller und automatischer Sync vereinheitlicht.** Beide Pfade laufen jetzt
ueber einen gemeinsamen `SyncCoordinator` und nutzen damit dieselben Schutz-
und Komfortfunktionen: lokaler Re-Entrancy-Schutz (prozessweit - auch manuell
vs. automatisch koennen nicht mehr gleichzeitig laufen), clientuebergreifende
Lock-Datei, Konflikt-Notizen anderer Arbeitsplaetze und vollstaendiges
Protokoll. Vorher umging der manuelle "Synchronisieren"-Button (Fenster) die
Lock-Datei und einen Teil des Protokolls.
- **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.
+2 -2
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.6"
#define MyAppVersion "0.0.3.1"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"
@@ -32,7 +32,7 @@ Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "Desktop-Verknuepfung erstellen"; GroupDescription: "Zusaetzliche Optionen:"
Name: "autostart"; Description: "Bei Windows-Anmeldung automatisch starten"; GroupDescription: "Zusaetzliche Optionen:"; Flags: checkedonce
Name: "autostart"; Description: "Bei Windows-Anmeldung automatisch starten (fuer ALLE Benutzer; einzelne Benutzer spaeter in den Einstellungen)"; GroupDescription: "Zusaetzliche Optionen:"; Flags: checkedonce
[Files]
; Hauptanwendung - Pfad anpassen nach Build
@@ -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";
}
}
+74 -4
View File
@@ -1,5 +1,14 @@
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
{
/// <summary>Welche Telefonanlage auf der anderen Seite steht.</summary>
public enum PhoneSystem
{
Starface,
Fonaria
}
public enum SyncDirection
{
Both,
@@ -7,8 +16,12 @@ namespace StarfaceOutlookSync.Models
StarfaceToOutlook
}
public class StarfaceConnection
public class SystemConnection
{
// Voreinstellung Starface, damit Profile aus der Zeit vor Fonaria
// unveraendert weiterlaufen: Was im JSON fehlt, ist eine Starface.
public PhoneSystem System { get; set; } = PhoneSystem.Starface;
public string Host { get; set; } = "";
public int Port { get; set; } = 443;
public bool UseSsl { get; set; } = true;
@@ -16,7 +29,16 @@ namespace StarfaceOutlookSync.Models
public string Password { get; set; } = "";
}
public class StarfaceAddressBook
/// <summary>
/// Ein Adressbuch auf der Gegenseite.
///
/// Die Felder heissen noch nach der Starface, weil dort ihre Bedeutung
/// herkommt: <c>Type</c> unterscheidet zentrales, persoenliches und
/// Tag-Adressbuch, <c>TagId</c> traegt die Kennung. Bei Fonaria steht in
/// <c>TagId</c> schlicht die Nummer des Adressbuchs und in <c>Type</c>
/// dessen Art (private, shared, system).
/// </summary>
public class RemoteAddressBook
{
public string Type { get; set; } = "central"; // central, user, tag
public string UserId { get; set; } = "";
@@ -30,8 +52,13 @@ namespace StarfaceOutlookSync.Models
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StarfaceConnection StarfaceConnection { get; set; } = new StarfaceConnection();
public StarfaceAddressBook StarfaceAddressBook { get; set; } = new StarfaceAddressBook();
// Die JSON-Namen bleiben, wie sie waren — sonst verloeren alle
// bestehenden Profile beim ersten Start ihre Verbindungsdaten.
[JsonProperty("StarfaceConnection")]
public SystemConnection Connection { get; set; } = new SystemConnection();
[JsonProperty("StarfaceAddressBook")]
public RemoteAddressBook AddressBook { get; set; } = new RemoteAddressBook();
public string OutlookFolderPath { get; set; } = "";
public string OutlookFolderName { get; set; } = "Kontakte";
public SyncDirection SyncDirection { get; set; } = SyncDirection.Both;
@@ -44,7 +71,27 @@ namespace StarfaceOutlookSync.Models
{
public string ProfileId { get; set; } = "";
public string OutlookEntryId { get; set; } = "";
// Die Kennung auf der Gegenseite — bei Fonaria die Kontakt-ID. Der
// Name bleibt: Er steht in jeder bereits gespeicherten Zuordnung, und
// ein Umbenennen wuerde sie alle entwerten.
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 +103,28 @@ 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>();
// Was konkret veraendert wurde (erstellt/aktualisiert/geloescht je Kontakt),
// fuer das Protokoll.
public System.Collections.Generic.List<string> Changes { 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";
}
}
@@ -2,6 +2,22 @@ namespace StarfaceOutlookSync.Models
{
public class UnifiedContact
{
/// <summary>
/// Welche vCard-Eigenschaften dieses Modell abbildet.
///
/// Fonaria fuehrt den Kontakt im vollen vCard-Umfang — Kategorien,
/// Jahrestag, Spitzname, Bild, zweite Anschrift. Hier steht davon nur
/// ein Ausschnitt, weil die Starface nicht mehr kann. Beim Schreiben
/// nach Fonaria wird diese Liste mitgeschickt: Sie sagt der Anlage,
/// wofuer wir zustaendig sind. Alles andere bleibt dort unangetastet,
/// statt geloescht zu werden, nur weil wir es nicht kennen.
/// </summary>
public static readonly string[] VcardFelder =
{
"FN", "N", "ORG", "TITLE", "TEL", "EMAIL", "ADR", "URL", "NOTE", "BDAY"
};
public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string FirstName { get; set; } = "";
@@ -1,5 +1,6 @@
using System;
using System.IO;
using Microsoft.Win32;
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
@@ -7,6 +8,22 @@ namespace StarfaceOutlookSync.Models
public class UserSettings
{
public bool StartMinimized { get; set; } = false;
public bool SyncOnStart { get; set; } = false;
public bool AutoAcceptOutlookPrompt { get; set; } = false;
// Tray-Benachrichtigungen. Allgemein = Info-Meldungen (Sync laeuft/fertig).
// WarnungenFehler = nur Konflikte und Fehler. Beide unabhaengig schaltbar.
public bool NotificationsEnabled { get; set; } = true;
public bool NotifyWarningsErrors { get; set; } = true;
// Protokoll-Eintraege aelter als X Tage automatisch entfernen. 0 = aus
// (alle Eintraege bleiben erhalten).
public int LogRetentionDays { get; set; } = 0;
// 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 +49,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,76 @@
using System;
using System.Diagnostics;
using System.Security.Principal;
using Microsoft.Win32;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Verwaltet den Windows-Autostart ueber die Run-Schluessel:
/// - pro Benutzer -> HKCU\...\Run
/// - alle Benutzer -> HKLM\...\Run (nur mit Admin-Rechten aenderbar)
/// Das Setup setzt den Autostart fuer alle Benutzer (HKLM); hierueber laesst
/// er sich nachtraeglich umstellen oder entfernen.
/// </summary>
public static class AutostartManager
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "StarfaceOutlookSync";
private static string ExePath()
{
try
{
var p = Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(p)) return p;
}
catch { }
return System.Reflection.Assembly.GetEntryAssembly()?.Location ?? "";
}
public static bool IsAdmin()
{
try
{
using (var id = WindowsIdentity.GetCurrent())
return new WindowsPrincipal(id).IsInRole(WindowsBuiltInRole.Administrator);
}
catch { return false; }
}
public static bool GetUserAutostart() => HasValue(Registry.CurrentUser);
public static bool GetMachineAutostart() => HasValue(Registry.LocalMachine);
public static void SetUserAutostart(bool enabled) => SetValue(Registry.CurrentUser, enabled);
/// <summary>Setzt/entfernt den Autostart fuer alle Benutzer. Braucht Admin-Rechte. Gibt Erfolg zurueck.</summary>
public static bool SetMachineAutostart(bool enabled) => SetValue(Registry.LocalMachine, enabled);
private static bool HasValue(RegistryKey root)
{
try
{
using (var k = root.OpenSubKey(RunKey, false))
return k?.GetValue(ValueName) != null;
}
catch { return false; }
}
private static bool SetValue(RegistryKey root, bool enabled)
{
try
{
using (var k = root.CreateSubKey(RunKey))
{
if (k == null) return false;
if (enabled)
k.SetValue(ValueName, "\"" + ExePath() + "\"", RegistryValueKind.String);
else if (k.GetValue(ValueName) != null)
k.DeleteValue(ValueName, false);
}
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,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Erkennt, ob zwei Kontakte dieselbe Person/Firma sind. Wird sowohl vom
/// Sync (Wiederzuordnung, Duplikat-Vermeidung) als auch von der
/// Dubletten-Zusammenfuehrung genutzt - beide MUESSEN dieselbe Logik
/// verwenden, sonst laufen sie gegeneinander.
/// </summary>
public static class ContactMatcher
{
public static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
foreach (var c in candidates)
if (IsMatch(contact, c))
return c;
return null;
}
public 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));
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);
// Telefon feldUEBERGREIFEND und formatunabhaengig (+49 == 0).
bool phoneMatch = SharedPhone(a, b);
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
bool emailContradiction = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email) && !emailMatch;
if (emailMatch) return true;
if (nameMatch && !emailContradiction) return true;
// Firma-/Service-Eintraege OHNE Personennamen: gleiche Firma + gemeinsame
// Nummer. Bewusst nur ohne Namen, damit nicht zwei Kollegen mit gleicher
// Zentrale-Nummer + Firma faelschlich verschmolzen werden.
bool aHasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName);
bool bHasName = !string.IsNullOrEmpty(b.FirstName) || !string.IsNullOrEmpty(b.LastName);
if (phoneMatch && companyMatch && !aHasName && !bHasName && !emailContradiction) return true;
// Reine Nummern-Eintraege: eine gemeinsame Rufnummer identifiziert den Eintrag.
if (phoneMatch && IsBareNumberEntry(a) && IsBareNumberEntry(b)) return true;
return false;
}
private static bool IsBareNumberEntry(UnifiedContact c) =>
string.IsNullOrEmpty(c.FirstName) && string.IsNullOrEmpty(c.LastName)
&& string.IsNullOrEmpty(c.Company) && string.IsNullOrEmpty(c.Email);
/// <summary>True, wenn a und b mindestens eine (normalisierte) Rufnummer gemeinsam haben.</summary>
public static bool SharedPhone(UnifiedContact a, UnifiedContact b)
{
var pa = PhoneSet(a);
if (pa.Count == 0) return false;
var pb = PhoneSet(b);
return pb.Count > 0 && pa.Overlaps(pb);
}
private static HashSet<string> PhoneSet(UnifiedContact c)
{
var set = new HashSet<string>();
foreach (var p in new[] { c.PhoneWork, c.PhoneMobile, c.PhoneHome, c.Fax })
{
var n = NormalizePhone(p);
if (n.Length >= 5) set.Add(n); // zu kurze (z.B. reine Durchwahlen) ignorieren
}
return set;
}
public static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
if (s.StartsWith("+49")) s = "0" + s.Substring(3);
else if (s.StartsWith("0049")) s = "0" + s.Substring(4);
else if (s.StartsWith("+")) s = s.Substring(1);
return s;
}
}
}
@@ -0,0 +1,178 @@
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>
/// Fuellt LEERE Felder von target aus den sources auf (erste nicht-leere
/// Quelle gewinnt). Vorhandene Werte von target bleiben unveraendert -
/// kein Datenverlust. Gibt true zurueck, wenn sich etwas geaendert hat.
/// </summary>
public static bool FillEmptyInto(UnifiedContact target, IEnumerable<UnifiedContact> sources)
{
bool changed = false;
var list = sources?.ToList() ?? new List<UnifiedContact>();
foreach (var f in Fields)
{
if (!string.IsNullOrEmpty(f.Get(target))) continue;
foreach (var s in list)
{
var v = f.Get(s);
if (!string.IsNullOrEmpty(v)) { f.Set(target, v); changed = true; break; }
}
}
return changed;
}
/// <summary>Anzahl gefuellter Inhaltsfelder - fuer die Wahl des "Gewinners".</summary>
public static int FilledFieldCount(UnifiedContact c)
{
if (c == null) return 0;
return Fields.Count(f => !string.IsNullOrEmpty(f.Get(c)));
}
/// <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 "";
var s = new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
if (s.StartsWith("+49")) s = "0" + s.Substring(3);
else if (s.StartsWith("0049")) s = "0" + s.Substring(4);
else if (s.StartsWith("+")) s = s.Substring(1);
return s;
}
}
}
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Fuehrt Dubletten innerhalb EINES Profils zusammen - getrennt je Seite
/// (Telefonanlage und Outlook). Regel: ein "Gewinner" bleibt, dessen leere
/// Felder aus den Dubletten aufgefuellt werden (kein Datenverlust), die
/// uebrigen werden geloescht. Danach werden verwaiste Zuordnungen entfernt,
/// damit der naechste (bidirektionale) Sync die Gewinner sauber 1:1 verbindet.
/// Nutzt dieselbe Erkennung wie der Sync (ContactMatcher).
/// </summary>
public class DedupeService
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly OutlookContactsService _outlookService = new OutlookContactsService();
public event Action<string> OnProgress;
private void Log(string m) => OnProgress?.Invoke(m);
public class DupGroup
{
public UnifiedContact Survivor;
public List<UnifiedContact> Duplicates = new List<UnifiedContact>();
}
public class DedupePlan
{
public List<DupGroup> RemoteGroups = new List<DupGroup>(); // Telefonanlage
public List<DupGroup> OutlookGroups = new List<DupGroup>();
public int RemoteDeletions => RemoteGroups.Sum(g => g.Duplicates.Count);
public int OutlookDeletions => OutlookGroups.Sum(g => g.Duplicates.Count);
public bool HasWork => RemoteGroups.Count > 0 || OutlookGroups.Count > 0;
}
public class DedupeReport
{
public int RemoteMerged, RemoteDeleted, OutlookMerged, OutlookDeleted, Errors;
public List<string> Messages = new List<string>();
}
/// <summary>Nur analysieren (lesend), fuer die Vorschau.</summary>
public async Task<DedupePlan> AnalyzeAsync(SyncProfile profile)
{
var plan = new DedupePlan();
var mappings = _profileManager.GetMappings(profile.Id);
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
var mappedOutlook = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId));
Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
Log($"Verbinde mit {profile.Connection.System}...");
using (var backend = ContactBackendFactory.Create(profile.Connection))
{
backend.OnDebug += Log;
if (!await backend.LoginAsync())
throw new Exception("Login an der Telefonanlage fehlgeschlagen.");
Log("Lade Kontakte der Telefonanlage...");
var remoteContacts = await backend.GetContactsAsync(profile.AddressBook);
await backend.LogoutAsync();
plan.RemoteGroups = GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId));
plan.OutlookGroups = GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId));
}
Log($"Analyse fertig: Anlage {plan.RemoteGroups.Count} Gruppe(n)/{plan.RemoteDeletions} zu loeschen, " +
$"Outlook {plan.OutlookGroups.Count} Gruppe(n)/{plan.OutlookDeletions} zu loeschen.");
return plan;
}
/// <summary>
/// Fuehrt die Zusammenfuehrung wirklich aus. Laedt frisch (unter Sperre
/// aufrufen!), gruppiert und wendet an. Gibt einen Bericht zurueck.
/// </summary>
public async Task<DedupeReport> ExecuteAsync(SyncProfile profile)
{
var report = new DedupeReport();
var mappings = _profileManager.GetMappings(profile.Id);
var mappedStarface = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.StarfaceId)).Select(m => m.StarfaceId));
var mappedOutlook = new HashSet<string>(mappings.Where(m => !string.IsNullOrEmpty(m.OutlookEntryId)).Select(m => m.OutlookEntryId));
var deletedStarfaceIds = new HashSet<string>();
var deletedOutlookIds = new HashSet<string>();
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
using (var backend = ContactBackendFactory.Create(profile.Connection))
{
backend.OnDebug += Log;
if (!await backend.LoginAsync())
throw new Exception("Login an der Telefonanlage fehlgeschlagen.");
var remoteContacts = await backend.GetContactsAsync(profile.AddressBook);
// --- Telefonanlage ---
foreach (var g in GroupDuplicates(remoteContacts, c => mappedStarface.Contains(c.StarfaceId)))
{
try
{
bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates);
bool survivorOk = true;
if (changed)
survivorOk = await backend.UpdateContactAsync(g.Survivor.StarfaceId, g.Survivor, profile.AddressBook) != null;
if (!survivorOk)
{
// Gewinner konnte nicht ergaenzt werden -> Dubletten NICHT loeschen
// (sonst gingen die nur dort vorhandenen Felder verloren).
report.Errors++;
report.Messages.Add($"Anlage: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - Gruppe uebersprungen.");
continue;
}
foreach (var dup in g.Duplicates)
{
if (await backend.DeleteContactAsync(dup.StarfaceId))
{
deletedStarfaceIds.Add(dup.StarfaceId);
report.RemoteDeleted++;
Log($" Anlage: Dublette geloescht -> zusammengefuehrt in '{g.Survivor.DisplayName}'");
}
}
report.RemoteMerged++;
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add($"Anlage '{g.Survivor.DisplayName}': {ex.Message}");
}
}
await backend.LogoutAsync();
}
// --- Outlook ---
foreach (var g in GroupDuplicates(outlookContacts, c => mappedOutlook.Contains(c.OutlookEntryId)))
{
try
{
bool changed = ContactMerger.FillEmptyInto(g.Survivor, g.Duplicates);
bool survivorOk = true;
if (changed)
survivorOk = _outlookService.UpdateContact(g.Survivor.OutlookEntryId, g.Survivor) != null;
if (!survivorOk)
{
report.Errors++;
report.Messages.Add($"Outlook: '{g.Survivor.DisplayName}' konnte nicht ergaenzt werden - Gruppe uebersprungen.");
continue;
}
foreach (var dup in g.Duplicates)
{
if (_outlookService.DeleteContact(dup.OutlookEntryId))
{
deletedOutlookIds.Add(dup.OutlookEntryId);
report.OutlookDeleted++;
Log($" Outlook: Dublette geloescht -> zusammengefuehrt in '{g.Survivor.DisplayName}'");
}
}
report.OutlookMerged++;
}
catch (Exception ex)
{
report.Errors++;
report.Messages.Add($"Outlook '{g.Survivor.DisplayName}': {ex.Message}");
}
}
// Verwaiste Zuordnungen (zeigen auf geloeschte Dubletten) entfernen.
// Der naechste Sync verbindet die Gewinner sauber neu.
if (deletedStarfaceIds.Count > 0 || deletedOutlookIds.Count > 0)
{
var kept = mappings
.Where(m => !deletedStarfaceIds.Contains(m.StarfaceId) && !deletedOutlookIds.Contains(m.OutlookEntryId))
.ToList();
_profileManager.SaveMappings(profile.Id, kept);
}
return report;
}
// ---- Gruppierung ----
private static List<DupGroup> GroupDuplicates(List<UnifiedContact> contacts, Func<UnifiedContact, bool> isMapped)
{
var groups = new List<DupGroup>();
if (contacts == null) return groups;
var used = new bool[contacts.Count];
for (int i = 0; i < contacts.Count; i++)
{
if (used[i]) continue;
var members = new List<UnifiedContact> { contacts[i] };
used[i] = true;
for (int j = i + 1; j < contacts.Count; j++)
{
if (used[j]) continue;
if (members.Any(m => ContactMatcher.IsMatch(m, contacts[j])))
{
members.Add(contacts[j]);
used[j] = true;
}
}
if (members.Count > 1)
{
var survivor = ChooseSurvivor(members, isMapped);
groups.Add(new DupGroup
{
Survivor = survivor,
Duplicates = members.Where(m => !ReferenceEquals(m, survivor)).ToList()
});
}
}
return groups;
}
private static UnifiedContact ChooseSurvivor(List<UnifiedContact> members, Func<UnifiedContact, bool> isMapped)
{
// 1) einen bereits zugeordneten bevorzugen (Kontinuitaet),
// 2) sonst den mit den meisten gefuellten Feldern.
return members
.OrderByDescending(m => isMapped(m) ? 1 : 0)
.ThenByDescending(m => ContactMerger.FilledFieldCount(m))
.First();
}
}
}
@@ -0,0 +1,432 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Die Gegenseite Fonaria.
///
/// Anders als die Starface haelt Fonaria jeden Kontakt als vollstaendige
/// vCard vor — mit Bild, Kategorien, Jahrestag, mehreren Anschriften. Das
/// Sync-Modell dieser Anwendung bildet davon nur einen Ausschnitt ab.
/// Deshalb schickt jeder Schreibvorgang <c>verwaltet</c> mit: die Liste der
/// Eigenschaften, fuer die wir uns zustaendig erklaeren. Die Anlage
/// schreibt genau diese in die vorhandene Karte und laesst den Rest stehen.
///
/// Ohne das waere der erste Abgleich verheerend: Ein aus Outlook
/// gespiegelter Kontakt haette hinterher kein Bild und keine Kategorien
/// mehr — nicht weil jemand sie geloescht haette, sondern weil wir sie
/// nicht kannten.
/// </summary>
public class FonariaApiClient : IContactBackend
{
private readonly HttpClient _http;
private readonly SystemConnection _connection;
private readonly string _baseUrl;
private string _token;
public event Action<string> OnDebug;
public string SystemName => "Fonaria";
public IReadOnlyList<string> VerwalteteFelder => UnifiedContact.VcardFelder;
public FonariaApiClient(SystemConnection connection)
{
_connection = connection;
var handler = new HttpClientHandler();
// Wie bei der Starface: Anlagen im eigenen Netz tragen oft ein
// selbst ausgestelltes Zertifikat.
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;
_http = new HttpClient(handler);
_http.Timeout = TimeSpan.FromSeconds(30);
var protocol = connection.UseSsl ? "https" : "http";
var portPart = (connection.UseSsl && connection.Port == 443) ||
(!connection.UseSsl && connection.Port == 80)
? "" : $":{connection.Port}";
_baseUrl = $"{protocol}://{connection.Host}{portPart}/api/v1";
}
// -------------------------------------------------------------------
// Anmeldung
// -------------------------------------------------------------------
public async Task<bool> LoginAsync()
{
try
{
var body = new { username = _connection.LoginId, password = _connection.Password };
var content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/auth/login", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Fonaria-Anmeldung fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return false;
}
_token = JObject.Parse(respBody)["access_token"]?.ToString();
if (string.IsNullOrEmpty(_token)) return false;
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _token);
return true;
}
catch (Exception ex)
{
OnDebug?.Invoke($"Fonaria-Anmeldung fehlgeschlagen: {ex.Message}");
return false;
}
}
public Task LogoutAsync()
{
// Fonaria-Token laufen von selbst ab; es gibt nichts abzumelden.
_token = null;
_http.DefaultRequestHeaders.Authorization = null;
return Task.CompletedTask;
}
// -------------------------------------------------------------------
// Lesen
// -------------------------------------------------------------------
public async Task<List<RemoteAddressBook>> GetAddressBooksAsync()
{
var buecher = new List<RemoteAddressBook>();
var resp = await _http.GetAsync($"{_baseUrl}/addressbooks");
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Adressbuecher nicht abrufbar: {(int)resp.StatusCode}");
return buecher;
}
foreach (var eintrag in JArray.Parse(await resp.Content.ReadAsStringAsync()))
{
var art = eintrag["kind"]?.ToString() ?? "shared";
// Das Benutzerverzeichnis pflegt die Anlage selbst und weist
// jeden Schreibversuch ab. Als Sync-Ziel waere es eine Falle.
if (art == "system") continue;
buecher.Add(new RemoteAddressBook
{
Type = art,
TagId = eintrag["id"]?.ToString() ?? "",
Name = eintrag["name"]?.ToString() ?? "",
UserId = eintrag["owner_id"]?.ToString() ?? ""
});
}
return buecher;
}
public async Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book)
{
// Bewusst mit full=1: Die Kurzform der Liste traegt nur Name, Firma
// und Nummern. Wer damit abgleicht, haelt jeden Kontakt fuer
// veraendert und schreibt bei jedem Durchlauf alles neu.
var url = $"{_baseUrl}/addressbooks/{book.TagId}/contacts?full=1&limit=5000";
HttpResponseMessage resp = null;
for (var versuch = 0; versuch < 3; versuch++)
{
try
{
resp = await _http.GetAsync(url);
if (resp.IsSuccessStatusCode) break;
}
catch (Exception ex)
{
OnDebug?.Invoke($"Kontaktabruf gescheitert (Versuch {versuch + 1}): {ex.Message}");
}
await Task.Delay(250 * (versuch + 1));
}
// Mit einer halben Liste weiterzuarbeiten ist gefaehrlicher als
// abzubrechen: Fehlende Kontakte gaelten als geloescht.
if (resp == null || !resp.IsSuccessStatusCode)
{
throw new Exception(
"Die Kontakte aus Fonaria konnten nicht geladen werden. " +
"Synchronisation abgebrochen, um Dubletten und Loeschungen zu vermeiden.");
}
var kontakte = new List<UnifiedContact>();
foreach (var eintrag in JArray.Parse(await resp.Content.ReadAsStringAsync()))
kontakte.Add(AusFonaria(eintrag));
OnDebug?.Invoke($"{kontakte.Count} Kontakte aus Fonaria-Adressbuch {book.Name} geladen");
return kontakte;
}
public async Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book)
{
try
{
var resp = await _http.GetAsync(
$"{_baseUrl}/addressbooks/{book.TagId}/contacts/{contactId}");
if (resp.StatusCode == HttpStatusCode.NotFound) return null;
if (!resp.IsSuccessStatusCode) return null;
return AusFonaria(JObject.Parse(await resp.Content.ReadAsStringAsync()));
}
catch { return null; }
}
// -------------------------------------------------------------------
// Schreiben
// -------------------------------------------------------------------
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, RemoteAddressBook book)
{
var body = NachFonaria(contact, null).ToString();
OnDebug?.Invoke($"POST /addressbooks/{book.TagId}/contacts");
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/addressbooks/{book.TagId}/contacts", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Anlegen fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
}
return AusFonaria(JObject.Parse(respBody));
}
public async Task<UnifiedContact> UpdateContactAsync(
string contactId, UnifiedContact contact, RemoteAddressBook book)
{
// Den bekannten Stand mitgeben, damit nichts verlorengeht, was
// innerhalb einer verwalteten Eigenschaft liegt, aber im
// Sync-Modell nicht vorkommt.
JToken roh;
if (!_rohdaten.TryGetValue(contactId, out roh))
{
var vorher = await GetContactAsync(contactId, book);
_rohdaten.TryGetValue(contactId, out roh);
}
var body = NachFonaria(contact, roh).ToString();
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PutAsync(
$"{_baseUrl}/addressbooks/{book.TagId}/contacts/{contactId}", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"Aendern von {contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
return null;
}
// Der Stand *nach* dem Schreiben ist massgeblich fuer die Baseline —
// sonst gilt der Kontakt beim naechsten Lauf erneut als geaendert.
return AusFonaria(JObject.Parse(respBody));
}
public async Task<bool> DeleteContactAsync(string contactId)
{
// Ohne Adressbuch im Pfad geht es bei Fonaria nicht. Die Engine
// ruft nur mit Kontakten des Profil-Buches, deshalb reicht das
// zuletzt benutzte.
if (_letztesBuch == null) return false;
var resp = await _http.DeleteAsync(
$"{_baseUrl}/addressbooks/{_letztesBuch}/contacts/{contactId}");
return resp.IsSuccessStatusCode;
}
private string _letztesBuch;
/// <summary>
/// Die Rohdaten der zuletzt gelesenen Kontakte, nach Kennung.
///
/// Sie werden beim Schreiben gebraucht: Zwei Dinge liegen innerhalb
/// einer Eigenschaft, die wir verwalten, gehoeren aber trotzdem nicht
/// uns — die Abteilung steckt im zweiten Teil von ORG, und neben der
/// geschaeftlichen Anschrift kann eine private stehen. Beides kennt das
/// Sync-Modell nicht. Wer es nicht zurueckschreibt, loescht es.
/// </summary>
private readonly Dictionary<string, JToken> _rohdaten = new Dictionary<string, JToken>();
// -------------------------------------------------------------------
// Umwandlung
// -------------------------------------------------------------------
private UnifiedContact AusFonaria(JToken item)
{
_letztesBuch = item["address_book_id"]?.ToString() ?? _letztesBuch;
var kennung = item["id"]?.ToString();
if (!string.IsNullOrEmpty(kennung)) _rohdaten[kennung] = item;
var kontakt = new UnifiedContact
{
StarfaceId = item["id"]?.ToString() ?? "",
FirstName = item["first_name"]?.ToString() ?? "",
LastName = item["last_name"]?.ToString() ?? "",
Company = item["organization"]?.ToString() ?? "",
JobTitle = item["title"]?.ToString() ?? "",
Salutation = item["prefix"]?.ToString() ?? "",
Notes = item["notes"]?.ToString() ?? "",
Birthday = item["birthday"]?.ToString() ?? ""
};
foreach (var mail in Eintraege(item["emails"]))
{
if (string.IsNullOrEmpty(kontakt.Email)) kontakt.Email = mail.Item1;
else if (string.IsNullOrEmpty(kontakt.EmailSecondary)) kontakt.EmailSecondary = mail.Item1;
}
// Die schlanke Liste fuehrt nur ein einzelnes Feld.
if (string.IsNullOrEmpty(kontakt.Email))
kontakt.Email = item["email"]?.ToString() ?? "";
foreach (var nummer in Nummern(item["numbers"]))
{
switch (nummer.Item2)
{
case "mobil": if (string.IsNullOrEmpty(kontakt.PhoneMobile)) kontakt.PhoneMobile = nummer.Item1; break;
case "home": if (string.IsNullOrEmpty(kontakt.PhoneHome)) kontakt.PhoneHome = nummer.Item1; break;
case "fax": if (string.IsNullOrEmpty(kontakt.Fax)) kontakt.Fax = nummer.Item1; break;
default: if (string.IsNullOrEmpty(kontakt.PhoneWork)) kontakt.PhoneWork = nummer.Item1; break;
}
}
var webadresse = Eintraege(item["urls"]).FirstOrDefault();
if (webadresse != null) kontakt.Website = webadresse.Item1;
// Von mehreren Anschriften traegt das Modell nur eine. Die
// geschaeftliche hat Vorrang — das ist die, um die es im
// Telefonbuch einer Anlage geht.
var anschriften = item["addresses"] as JArray;
if (anschriften != null && anschriften.Count > 0)
{
var gewaehlt = anschriften.FirstOrDefault(a => a["art"]?.ToString() == "work")
?? anschriften[0];
kontakt.Street = gewaehlt["strasse"]?.ToString() ?? "";
kontakt.City = gewaehlt["ort"]?.ToString() ?? "";
kontakt.PostalCode = gewaehlt["plz"]?.ToString() ?? "";
kontakt.State = gewaehlt["region"]?.ToString() ?? "";
kontakt.Country = gewaehlt["land"]?.ToString() ?? "";
}
return kontakt;
}
private static List<Tuple<string, string>> Eintraege(JToken liste)
{
var raus = new List<Tuple<string, string>>();
if (!(liste is JArray felder)) return raus;
foreach (var f in felder)
{
var wert = f["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(wert))
raus.Add(Tuple.Create(wert, f["kind"]?.ToString() ?? ""));
}
return raus;
}
private static List<Tuple<string, string>> Nummern(JToken liste)
{
var raus = new List<Tuple<string, string>>();
if (!(liste is JArray felder)) return raus;
foreach (var f in felder)
{
// Die Kurzform der Liste nennt das Feld "number", die
// Vollansicht "value".
var wert = f["number"]?.ToString() ?? f["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(wert))
raus.Add(Tuple.Create(wert, f["kind"]?.ToString() ?? "work"));
}
return raus;
}
private JObject NachFonaria(UnifiedContact contact, JToken bekannt)
{
var nummern = new JArray();
void Nummer(string wert, string art)
{
if (!string.IsNullOrWhiteSpace(wert))
nummern.Add(new JObject { ["value"] = wert, ["kind"] = art });
}
Nummer(contact.PhoneWork, "work");
Nummer(contact.PhoneMobile, "mobil");
Nummer(contact.PhoneHome, "home");
Nummer(contact.Fax, "fax");
var mails = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Email))
mails.Add(new JObject { ["value"] = contact.Email, ["kind"] = "work" });
if (!string.IsNullOrWhiteSpace(contact.EmailSecondary))
mails.Add(new JObject { ["value"] = contact.EmailSecondary, ["kind"] = "home" });
var webadressen = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Website))
webadressen.Add(new JObject { ["value"] = contact.Website, ["kind"] = "work" });
// Das Sync-Modell traegt genau eine Anschrift, die geschaeftliche.
// Alle uebrigen der Anlage werden unveraendert wieder mitgeschickt.
var anschriften = new JArray();
if (!string.IsNullOrWhiteSpace(contact.Street) ||
!string.IsNullOrWhiteSpace(contact.City) ||
!string.IsNullOrWhiteSpace(contact.PostalCode))
{
anschriften.Add(new JObject
{
["art"] = "work",
["strasse"] = contact.Street ?? "",
["ort"] = contact.City ?? "",
["plz"] = contact.PostalCode ?? "",
["region"] = contact.State ?? "",
["land"] = contact.Country ?? ""
});
}
if (bekannt?["addresses"] is JArray vorhandene)
{
foreach (var a in vorhandene)
if (a["art"]?.ToString() != "work")
anschriften.Add(a.DeepClone());
}
// Dasselbe fuer die Abteilung: Sie steckt im zweiten Teil von ORG,
// das Sync-Modell kennt nur die Firma.
var abteilung = bekannt?["department"]?.ToString() ?? "";
return new JObject
{
// Der Kern: Nur diese Eigenschaften fasst Fonaria an.
["verwaltet"] = new JArray(VerwalteteFelder),
["display_name"] = contact.DisplayName,
["prefix"] = contact.Salutation ?? "",
["first_name"] = contact.FirstName ?? "",
["last_name"] = contact.LastName ?? "",
["organization"] = contact.Company ?? "",
["department"] = abteilung,
["title"] = contact.JobTitle ?? "",
["numbers"] = nummern,
["emails"] = mails,
["urls"] = webadressen,
["addresses"] = anschriften,
["birthday"] = contact.Birthday ?? "",
["notes"] = string.IsNullOrEmpty(contact.Notes) ? null : contact.Notes
};
}
public void Dispose()
{
_http?.Dispose();
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Die Gegenseite eines Abgleichs — eine Telefonanlage mit Adressbuechern.
///
/// Die SyncEngine kennt nur dieses Interface. Was dahinter steckt, eine
/// Starface oder eine Fonaria, entscheidet allein das Profil. Ohne diese
/// Trennung muesste die Engine fuer jede weitere Anlage angefasst werden —
/// und in ihr steckt die ganze Erfahrung mit getrennten Baselines,
/// Wiederzuordnung und Konfliktaufloesung, die man nicht zweimal haben will.
/// </summary>
public interface IContactBackend : IDisposable
{
/// <summary>Ausfuehrliche Meldungen fuer das Protokoll.</summary>
event Action<string> OnDebug;
/// <summary>Welche Anlage ist das? Nur fuer Meldungen an den Anwender.</summary>
string SystemName { get; }
/// <summary>
/// Welche vCard-Eigenschaften diese Anlage ueberhaupt fuehrt.
///
/// Entscheidend beim Schreiben: Fonaria haelt den Kontakt im vollen
/// vCard-Umfang. Ein Abgleich mit einer Starface darf deshalb nicht
/// die ganze Karte ersetzen — die Starface kennt weder Kategorien noch
/// Jahrestag noch Bild und wuerde sie beim ersten Durchlauf
/// mitloeschen, nur weil sie sie nicht mitschickt. Diese Liste sagt der
/// Gegenseite, wofuer wir uns zustaendig fuehlen.
/// </summary>
IReadOnlyList<string> VerwalteteFelder { get; }
Task<bool> LoginAsync();
Task LogoutAsync();
Task<List<RemoteAddressBook>> GetAddressBooksAsync();
Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book);
Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book);
Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, RemoteAddressBook book);
Task<UnifiedContact> UpdateContactAsync(string contactId, UnifiedContact contact, RemoteAddressBook book);
Task<bool> DeleteContactAsync(string contactId);
}
/// <summary>Waehlt die Umsetzung, die zum Profil passt.</summary>
public static class ContactBackendFactory
{
public static IContactBackend Create(SystemConnection connection)
{
switch (connection.System)
{
case PhoneSystem.Fonaria:
return new FonariaApiClient(connection);
default:
return new StarfaceApiClient(connection);
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Globalization;
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 ""; }
}
/// <summary>
/// Entfernt Protokoll-Eintraege, die aelter als <paramref name="days"/>
/// Tage sind. days &lt;= 0 -> nichts tun (alle Eintraege bleiben).
/// </summary>
public static void PruneOlderThan(int days)
{
if (days <= 0) return;
try
{
lock (_lock)
{
if (!File.Exists(LogFilePath)) return;
var cutoff = DateTime.Now.AddDays(-days);
var lines = File.ReadAllLines(LogFilePath, Encoding.UTF8);
var kept = new List<string>(lines.Length);
foreach (var line in lines)
{
// Eintraege ohne erkennbaren Zeitstempel (z.B. Fortsetzungs-
// zeilen) bleiben erhalten; nur datierte Alt-Eintraege fliegen raus.
if (TryParseLineDate(line, out var dt) && dt < cutoff)
continue;
kept.Add(line);
}
if (kept.Count != lines.Length)
File.WriteAllLines(LogFilePath, kept, Encoding.UTF8);
}
}
catch { }
}
private static bool TryParseLineDate(string line, out DateTime dt)
{
dt = default;
// Format: "[yyyy-MM-dd HH:mm:ss] ..."
if (line != null && line.Length >= 21 && line[0] == '[' && line[20] == ']')
{
return DateTime.TryParseExact(line.Substring(1, 19), "yyyy-MM-dd HH:mm:ss",
CultureInfo.InvariantCulture, DateTimeStyles.None, out dt);
}
return false;
}
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 { }
}
}
}
@@ -2,46 +2,62 @@ using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using StarfaceOutlookSync.Models;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Zugriff auf Outlook-Kontakte per dynamic COM.
/// Funktioniert mit jeder Outlook Classic Version (2013-2024)
/// ohne Abhaengigkeit von einer bestimmten Interop-DLL.
/// </summary>
public class OutlookContactsService : IDisposable
{
private Outlook.Application _outlookApp;
private dynamic _outlookApp;
private bool _weStartedOutlook;
// Marshal.GetActiveObject existiert nicht in .NET 8, daher P/Invoke
// OlDefaultFolders.olFolderContacts = 10
private const int OlFolderContacts = 10;
// OlItemType.olContactItem = 2
private const int OlContactItem = 2;
[DllImport("oleaut32.dll", PreserveSig = false)]
private static extern void GetActiveObject([MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, IntPtr pvReserved, [MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
private static extern void GetActiveObject(
[MarshalAs(UnmanagedType.LPStruct)] Guid rclsid,
IntPtr pvReserved,
[MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
private static object GetActiveComObject(string progId)
{
var clsid = Type.GetTypeFromProgID(progId, true).GUID;
GetActiveObject(clsid, IntPtr.Zero, out var obj);
var type = Type.GetTypeFromProgID(progId, false);
if (type == null) return null;
GetActiveObject(type.GUID, IntPtr.Zero, out var obj);
return obj;
}
private Outlook.Application GetOutlookApp()
private dynamic GetOutlookApp()
{
if (_outlookApp != null) return _outlookApp;
// Versuch 1: Laufende Outlook-Instanz finden
try
{
_outlookApp = (Outlook.Application)GetActiveComObject("Outlook.Application");
_weStartedOutlook = false;
return _outlookApp;
var obj = GetActiveComObject("Outlook.Application");
if (obj != null)
{
_outlookApp = obj;
_weStartedOutlook = false;
return _outlookApp;
}
}
catch { }
// Versuch 2: Outlook per COM starten
try
{
var outlookType = Type.GetTypeFromProgID("Outlook.Application");
var outlookType = Type.GetTypeFromProgID("Outlook.Application", false);
if (outlookType != null)
{
_outlookApp = (Outlook.Application)Activator.CreateInstance(outlookType);
_outlookApp = Activator.CreateInstance(outlookType);
_weStartedOutlook = true;
return _outlookApp;
}
@@ -65,18 +81,21 @@ namespace StarfaceOutlookSync.Services
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Alle Stores durchgehen (jedes Konto, jede PST-Datei etc.)
foreach (Outlook.Store store in ns.Stores)
// Alle Stores durchgehen
var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++)
{
try
{
var store = stores[i];
var rootFolder = store.GetRootFolder();
FindContactFoldersRecursive(rootFolder, folders);
Marshal.ReleaseComObject(rootFolder);
Marshal.ReleaseComObject(store);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning store '{store.DisplayName}': {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Error scanning store {i}: {ex.Message}");
}
}
@@ -85,121 +104,212 @@ namespace StarfaceOutlookSync.Services
{
try
{
var defaultFolder = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
folders.Add(defaultFolder.FolderPath);
var defaultFolder = ns.GetDefaultFolder(OlFolderContacts);
folders.Add((string)defaultFolder.FolderPath);
Marshal.ReleaseComObject(defaultFolder);
}
catch { }
}
Marshal.ReleaseComObject(stores);
Marshal.ReleaseComObject(ns);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error getting folders: {ex.Message}");
throw;
}
return folders;
}
private void FindContactFoldersRecursive(Outlook.MAPIFolder folder, List<string> paths)
private void FindContactFoldersRecursive(dynamic folder, List<string> paths)
{
try
{
// Kontaktordner erkennen: DefaultItemType ODER Ordnername enthaelt "Kontakt"/"Contact"
if (folder.DefaultItemType == Outlook.OlItemType.olContactItem)
if ((int)folder.DefaultItemType == OlContactItem)
{
if (!paths.Contains(folder.FolderPath))
paths.Add(folder.FolderPath);
string path = folder.FolderPath;
if (!paths.Contains(path))
paths.Add(path);
}
// Alle Unterordner durchsuchen
foreach (Outlook.MAPIFolder sub in folder.Folders)
var subFolders = folder.Folders;
for (int i = 1; i <= (int)subFolders.Count; i++)
{
try
{
var sub = subFolders[i];
FindContactFoldersRecursive(sub, paths);
}
catch { }
finally
{
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subFolders);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning folder '{folder.Name}': {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Error scanning folder: {ex.Message}");
}
}
private Outlook.MAPIFolder GetFolderByPath(string folderPath)
private dynamic FindFolderByPath(dynamic folder, string targetPath)
{
try
{
string currentPath = folder.FolderPath;
if (currentPath == targetPath)
return folder;
var subs = folder.Folders;
for (int i = 1; i <= (int)subs.Count; i++)
{
try
{
var sub = subs[i];
var match = FindFolderByPath(sub, targetPath);
if (match != null) return match;
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subs);
}
catch { }
return null;
}
private dynamic GetFolderByPath(string folderPath)
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Standard-Kontaktordner als Fallback
if (string.IsNullOrEmpty(folderPath))
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
return ns.GetDefaultFolder(OlFolderContacts);
try
{
// Pfad durchlaufen
var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
Outlook.MAPIFolder current = null;
foreach (Outlook.Store store in ns.Stores)
// Versuch 1: Alle Kontaktordner durchsuchen und per FolderPath matchen
var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++)
{
if (store.GetRootFolder().Name == parts[0] ||
store.GetRootFolder().FolderPath.TrimStart('\\') == parts[0])
try
{
current = store.GetRootFolder();
break;
}
}
if (current == null)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
for (int i = 1; i < parts.Length; i++)
{
bool found = false;
foreach (Outlook.MAPIFolder sub in current.Folders)
{
if (sub.Name == parts[i])
var store = stores[i];
var root = store.GetRootFolder();
var match = FindFolderByPath(root, folderPath);
if (match != null)
{
current = sub;
found = true;
break;
Marshal.ReleaseComObject(stores);
return match;
}
Marshal.ReleaseComObject(root);
Marshal.ReleaseComObject(store);
}
if (!found)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
catch { }
}
Marshal.ReleaseComObject(stores);
// Versuch 2: Namespace.Folders direkt navigieren
var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 1)
{
var topFolders = ns.Folders;
for (int i = 1; i <= (int)topFolders.Count; i++)
{
var topFolder = topFolders[i];
if ((string)topFolder.Name == parts[0])
{
dynamic current = topFolder;
for (int p = 1; p < parts.Length; p++)
{
bool found = false;
var subs = current.Folders;
for (int j = 1; j <= (int)subs.Count; j++)
{
var sub = subs[j];
if ((string)sub.Name == parts[p])
{
current = sub;
found = true;
break;
}
Marshal.ReleaseComObject(sub);
}
Marshal.ReleaseComObject(subs);
if (!found) { current = null; break; }
}
if (current != null)
{
Marshal.ReleaseComObject(topFolders);
return current;
}
}
Marshal.ReleaseComObject(topFolder);
}
Marshal.ReleaseComObject(topFolders);
}
return current;
System.Diagnostics.Debug.WriteLine($"Folder not found by path: {folderPath}, using default");
return ns.GetDefaultFolder(OlFolderContacts);
}
catch
{
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
return ns.GetDefaultFolder(OlFolderContacts);
}
}
public List<UnifiedContact> GetContacts(string folderPath)
{
var contacts = new List<UnifiedContact>();
try
{
var folder = GetFolderByPath(folderPath);
var items = folder.Items;
System.Diagnostics.Debug.WriteLine($"Reading contacts from: {(string)folder.FolderPath}");
foreach (var item in items)
var items = folder.Items;
int count = (int)items.Count;
System.Diagnostics.Debug.WriteLine($"Items count: {count}");
for (int i = 1; i <= count; i++)
{
if (item is Outlook.ContactItem ci)
dynamic item = null;
try
{
contacts.Add(MapFromOutlook(ci));
Marshal.ReleaseComObject(ci);
item = items[i];
int itemClass = -1;
try { itemClass = (int)item.Class; } catch { }
// olContact = 40, aber manche Kontakte melden Class anders
// Versuch einfach die Kontakt-Felder zu lesen
if (itemClass == 40)
{
contacts.Add(MapFromOutlook(item));
}
else
{
// Fallback: Pruefen ob es trotzdem ein Kontakt ist
try
{
string eid = item.EntryID;
string fn = item.FirstName;
string ln = item.LastName;
// Hat Kontakt-Felder -> ist ein Kontakt
contacts.Add(MapFromOutlook(item));
}
catch
{
// Kein Kontakt, ueberspringen
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error reading item {i}: {ex.Message}");
}
finally
{
if (item != null) try { Marshal.ReleaseComObject(item); } catch { }
}
}
@@ -219,7 +329,8 @@ namespace StarfaceOutlookSync.Services
try
{
var folder = GetFolderByPath(folderPath);
var ci = (Outlook.ContactItem)folder.Items.Add(Outlook.OlItemType.olContactItem);
var items = folder.Items;
dynamic ci = items.Add(OlContactItem);
MapToOutlook(contact, ci);
ci.Save();
@@ -227,6 +338,7 @@ namespace StarfaceOutlookSync.Services
contact.OutlookEntryId = ci.EntryID;
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(items);
Marshal.ReleaseComObject(folder);
return contact;
@@ -238,26 +350,34 @@ 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
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
var ci = (Outlook.ContactItem)ns.GetItemFromID(entryId);
dynamic ci = ns.GetItemFromID(entryId);
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;
}
}
@@ -267,7 +387,7 @@ namespace StarfaceOutlookSync.Services
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
var ci = (Outlook.ContactItem)ns.GetItemFromID(entryId);
dynamic ci = ns.GetItemFromID(entryId);
ci.Delete();
Marshal.ReleaseComObject(ci);
@@ -282,36 +402,58 @@ namespace StarfaceOutlookSync.Services
}
}
private UnifiedContact MapFromOutlook(Outlook.ContactItem ci)
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 = ci.EntryID ?? "",
FirstName = ci.FirstName ?? "",
LastName = ci.LastName ?? "",
Company = ci.CompanyName ?? "",
JobTitle = ci.JobTitle ?? "",
Email = ci.Email1Address ?? "",
EmailSecondary = ci.Email2Address ?? "",
PhoneWork = ci.BusinessTelephoneNumber ?? "",
PhoneMobile = ci.MobileTelephoneNumber ?? "",
PhoneHome = ci.HomeTelephoneNumber ?? "",
Fax = ci.BusinessFaxNumber ?? "",
Street = ci.BusinessAddressStreet ?? "",
City = ci.BusinessAddressCity ?? "",
PostalCode = ci.BusinessAddressPostalCode ?? "",
State = ci.BusinessAddressState ?? "",
Country = ci.BusinessAddressCountry ?? "",
Website = ci.WebPage ?? "",
Notes = ci.Body ?? "",
Salutation = ci.Title ?? "",
Title = ci.Suffix ?? "",
Birthday = ci.Birthday != DateTime.MinValue && ci.Birthday.Year > 1900
? ci.Birthday.ToString("yyyy-MM-dd") : ""
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)
};
}
private void MapToOutlook(UnifiedContact contact, Outlook.ContactItem ci)
private string GetBirthdayString(dynamic ci)
{
try
{
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 { }
return "";
}
private void MapToOutlook(UnifiedContact contact, dynamic ci)
{
ci.FirstName = contact.FirstName;
ci.LastName = contact.LastName;
@@ -348,7 +490,7 @@ namespace StarfaceOutlookSync.Services
{
try { _outlookApp.Quit(); } catch { }
}
Marshal.ReleaseComObject(_outlookApp);
try { Marshal.ReleaseComObject(_outlookApp); } catch { }
_outlookApp = null;
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
@@ -11,14 +12,25 @@ using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
public class StarfaceApiClient : IDisposable
public class StarfaceApiClient : IContactBackend
{
public string SystemName => "Starface";
/// <summary>
/// Was eine Starface an Feldern kennt — mehr gibt ihr Datenmodell nicht
/// her. Beim Schreiben nach Fonaria zaehlt genau diese Liste: Alles
/// andere in der dortigen vCard bleibt unangetastet, statt hier
/// mangels Kenntnis geloescht zu werden.
/// </summary>
public IReadOnlyList<string> VerwalteteFelder => UnifiedContact.VcardFelder;
private readonly HttpClient _http;
private readonly StarfaceConnection _connection;
private readonly SystemConnection _connection;
private readonly string _baseUrl;
private string _token;
public StarfaceApiClient(StarfaceConnection connection)
public StarfaceApiClient(SystemConnection connection)
{
_connection = connection;
@@ -111,55 +123,78 @@ namespace StarfaceOutlookSync.Services
catch { return null; }
}
public async Task<List<StarfaceAddressBook>> GetAddressBooksAsync()
public async Task<List<RemoteAddressBook>> GetAddressBooksAsync()
{
var books = new List<StarfaceAddressBook>();
var books = new List<RemoteAddressBook>();
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 RemoteAddressBook
{
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 RemoteAddressBook
{
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 RemoteAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = tagName
});
}
return books;
}
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
public event Action<string> OnDebug;
public async Task<List<UnifiedContact>> GetContactsAsync(RemoteAddressBook book)
{
var contacts = new List<UnifiedContact>();
int page = 0;
const int pageSize = 200;
bool firstPage = true;
while (true)
{
@@ -170,13 +205,88 @@ 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;
// Die API gibt je nach Version ein Array oder ein Objekt mit "items" zurueck
var token = JToken.Parse(body);
if (token is JArray directArray)
{
array = directArray;
}
else if (token is JObject obj)
{
// Versuche gaengige Felder: items, contacts, data, results
array = (obj["items"] ?? obj["contacts"] ?? obj["data"] ?? obj["results"]) as JArray;
if (array == null)
{
// Einzelnes Kontakt-Objekt? Dann in Array wrappen
if (obj["id"] != null && obj["blocks"] != null)
{
array = new JArray { obj };
}
else
{
System.Diagnostics.Debug.WriteLine($"Unerwartete Starface-Antwort: {body.Substring(0, Math.Min(200, body.Length))}");
break;
}
}
}
else
{
break;
}
var array = JArray.Parse(await resp.Content.ReadAsStringAsync());
if (array.Count == 0) break;
foreach (var item in array)
contacts.Add(MapFromStarface(item));
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
if (firstPage)
{
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;
}
// 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++;
@@ -185,32 +295,135 @@ namespace StarfaceOutlookSync.Services
return contacts;
}
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
/// <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, RemoteAddressBook 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, RemoteAddressBook 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, book) ?? contact;
}
/// <summary>Laedt einen einzelnen Kontakt mit allen Feldern.
///
/// Das Adressbuch spielt hier keine Rolle: Die Starface findet einen
/// Kontakt ueber alle Buecher hinweg. Genau darauf beruht die Regel,
/// dass ein nicht in der Liste gefundener Kontakt nur verschoben und
/// nicht geloescht ist.</summary>
public async Task<UnifiedContact> GetContactAsync(string contactId, RemoteAddressBook book = null)
{
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)
@@ -224,7 +437,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)
{
@@ -234,79 +450,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
};
}
@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Gemeinsame Orchestrierung fuer JEDEN Sync-Lauf - egal ob automatisch
/// (Tray/Timer) oder manuell (Fortschrittsfenster). Sorgt einheitlich fuer:
/// - lokalen Re-Entrancy-Schutz (prozessweit, statisch),
/// - clientuebergreifende Lock-Datei,
/// - Anzeige/Logging von Konflikt-Notizen anderer Arbeitsplaetze,
/// - Protokollierung (Start, Ergebnis, Aenderungen, Konflikte, Fehler),
/// - Verteilen eigener Konflikt-Notizen.
/// Die eigentliche UI (Tray-Meldung vs. Fenster) bleibt beim Aufrufer.
/// </summary>
public class SyncCoordinator
{
// Prozessweit: nie zwei Syncs gleichzeitig - auch nicht manuell + automatisch.
private static int _running = 0;
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly ConflictNotifier _notifier = new ConflictNotifier();
public class SyncOutcome
{
public bool Skipped;
public string SkipReason = "";
public SyncResult Result;
public Exception Error;
public List<ConflictNotice> RemoteNotices = new List<ConflictNotice>();
}
/// <summary>
/// Fuehrt einen Sync koordiniert aus. runEngine kapselt den eigentlichen
/// Engine-Aufruf (inkl. ggf. Task.Run/Progress-Anbindung des Aufrufers),
/// damit das Threading-Verhalten pro Pfad erhalten bleibt. status erhaelt
/// Fortschrittstexte (Warten auf Sperre, Synchronisiere ...).
/// </summary>
public async Task<SyncOutcome> RunAsync(SyncProfile profile,
Func<Task<SyncResult>> runEngine, Action<string> status)
{
var outcome = new SyncOutcome();
if (Interlocked.CompareExchange(ref _running, 1, 0) != 0)
{
outcome.Skipped = true;
outcome.SkipReason = "Sync laeuft bereits, bitte warten...";
return outcome;
}
SyncLock crossLock = null;
var settings = UserSettings.Load();
Logger.PruneOlderThan(settings.LogRetentionDays);
var sharedDir = settings.SharedDirectory;
try
{
crossLock = await AcquireCrossClientLock(sharedDir, status);
if (crossLock == null)
{
outcome.Skipped = true;
outcome.SkipReason = "Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.";
Logger.Log($"Sync '{profile.Name}' uebersprungen: anderer Arbeitsplatz synct gerade.");
return outcome;
}
// Konflikt-Hinweise anderer Arbeitsplaetze ermitteln, BEVOR der Sync
// den eigenen Stand auf den Gewinner-Wert angleicht.
try
{
outcome.RemoteNotices = _notifier.GetPending(sharedDir, MyContactsByStarfaceId());
foreach (var n in outcome.RemoteNotices)
Logger.Log($" KONFLIKT (anderer Arbeitsplatz): {n}");
}
catch { }
Logger.Log($"Sync gestartet: '{profile.Name}' (Richtung: {profile.SyncDirection})");
status?.Invoke($"Synchronisiere '{profile.Name}'...");
SyncResult result;
try
{
result = await runEngine();
}
catch (Exception ex)
{
outcome.Error = ex;
Logger.Log($"Sync FEHLER '{profile.Name}': {ex.Message}");
return outcome;
}
outcome.Result = result;
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert"
+ (result.Errors > 0 ? $", {result.Errors} Fehler" : "")
+ (result.Conflicts.Count > 0 ? $", {result.Conflicts.Count} Konflikt(e)" : "");
Logger.Log($"Sync fertig: {msg}");
foreach (var ch in result.Changes) Logger.Log($" {ch}");
foreach (var em in result.ErrorMessages) Logger.Log($" Fehler: {em}");
foreach (var c in result.Conflicts) Logger.Log($" KONFLIKT: {c}");
// Andere Arbeitsplaetze ueber eigene Konflikte informieren.
if (result.Conflicts.Count > 0)
_notifier.Write(sharedDir, result.Conflicts);
return outcome;
}
finally
{
crossLock?.Dispose();
Interlocked.Exchange(ref _running, 0);
}
}
/// <summary>
/// Fuehrt eine beliebige exklusive Aktion (z.B. Dubletten zusammenfuehren)
/// unter demselben Schutz wie ein Sync aus: lokaler Guard + clientueber-
/// greifende Lock-Datei. Gibt false zurueck, wenn gerade ein Sync/eine
/// Aktion laeuft oder ein anderer Arbeitsplatz aktiv ist.
/// </summary>
public async Task<bool> RunExclusiveAsync(Func<Task> action, Action<string> status)
{
if (Interlocked.CompareExchange(ref _running, 1, 0) != 0)
{
status?.Invoke("Es laeuft bereits ein Sync / eine Aktion - bitte warten.");
return false;
}
SyncLock crossLock = null;
var sharedDir = UserSettings.Load().SharedDirectory;
try
{
crossLock = await AcquireCrossClientLock(sharedDir, status);
if (crossLock == null)
{
status?.Invoke("Anderer Arbeitsplatz ist gerade aktiv - abgebrochen.");
return false;
}
await action();
return true;
}
finally
{
crossLock?.Dispose();
Interlocked.Exchange(ref _running, 0);
}
}
private async Task<SyncLock> AcquireCrossClientLock(string dir, Action<string> status)
{
if (string.IsNullOrWhiteSpace(dir))
return SyncLock.NoOp();
try
{
// Legt das Verzeichnis bei Bedarf an (idempotent). Funktioniert mit
// UNC-Pfaden (\\server\freigabe\...) - kein Netzlaufwerk noetig.
Directory.CreateDirectory(dir);
}
catch
{
status?.Invoke("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++;
status?.Invoke($"Warte auf anderen Arbeitsplatz ({heldBy ?? "unbekannt"})... ({attempt})");
await Task.Delay(3000);
}
}
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;
}
}
}
+479 -116
View File
@@ -15,28 +15,25 @@ namespace StarfaceOutlookSync.Services
private void Log(string message) => OnProgress?.Invoke(message);
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
/// <summary>Protokolliert eine tatsaechliche Aenderung (fuer Live-Log UND SyncResult.Changes).</summary>
private void Action(SyncResult result, string message)
{
// 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;
}
Log(" " + message);
result?.Changes.Add(message);
}
// Dann Name-Match
if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
{
var byName = candidates.FirstOrDefault(c =>
c.FirstName.Equals(contact.FirstName, StringComparison.OrdinalIgnoreCase) &&
c.LastName.Equals(contact.LastName, StringComparison.OrdinalIgnoreCase) &&
(!string.IsNullOrEmpty(c.FirstName) || !string.IsNullOrEmpty(c.LastName)));
if (byName != null) return byName;
}
return null;
/// <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 = "";
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
@@ -49,10 +46,10 @@ namespace StarfaceOutlookSync.Services
try
{
// Starface verbinden
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
using (var starface = ContactBackendFactory.Create(profile.Connection))
{
starface.OnDebug += (msg) => Log(msg);
var loginOk = await starface.LoginAsync();
if (!loginOk)
{
@@ -61,157 +58,523 @@ 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);
Log($"{outlookContacts.Count} Outlook-Kontakte geladen");
Log("Lade Starface-Kontakte...");
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
var starfaceContacts = await starface.GetContactsAsync(profile.AddressBook);
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 = ContactMatcher.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++;
Action(result, $"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++;
Action(result, $"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, profile.AddressBook) != 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++;
Action(result, $"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++;
Action(result, $"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.AddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
result.Updated++;
Action(result, $"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++;
Action(result, $"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.AddressBook);
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);
Action(result, 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.AddressBook);
if (updated != null)
{
SetBaseline(mapping, oc, updated);
result.Updated++;
Action(result, $"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++;
Action(result, $"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 = ContactMatcher.FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
var updated = await starface.UpdateContactAsync(match.StarfaceId, oc, profile.AddressBook);
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++;
Action(result, $"Verknuepft (OL->SF): {oc.DisplayName}");
}
}
else
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.AddressBook);
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++;
Action(result, $"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 = ContactMatcher.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++;
Action(result, $"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++;
Action(result, $"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++;
Action(result, $"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++;
Action(result, $"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.6</Version>
<AssemblyVersion>0.0.0.6</AssemblyVersion>
<FileVersion>0.0.0.6</FileVersion>
<Version>0.0.3.1</Version>
<AssemblyVersion>0.0.3.1</AssemblyVersion>
<FileVersion>0.0.3.1</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
@@ -19,7 +19,6 @@
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Office.Interop.Outlook" Version="15.0.4797.1004" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label
{
Text = "Version 0.0.0.6",
Text = "Version 0.0.3.1",
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);
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
/// <summary>
/// Dialog zum Zusammenfuehren doppelter Kontakte fuer EIN Profil.
/// Analysiert zuerst (Vorschau), fuehrt nach Bestaetigung unter derselben
/// Sperre wie ein Sync zusammen.
/// </summary>
public class DedupeForm : Form
{
private readonly SyncProfile _profile;
private readonly DedupeService _dedupe = new DedupeService();
private readonly SyncCoordinator _coordinator = new SyncCoordinator();
private Label _lblInfo, _lblSummary;
private CheckBox _chkDetail;
private TextBox _txtLog;
private Button _btnStart, _btnClose;
private DedupeService.DedupePlan _plan;
private bool _busy;
public DedupeForm(SyncProfile profile)
{
_profile = profile;
_dedupe.OnProgress += AppendLog;
InitializeComponent();
Load += async (s, e) => await Analyze();
}
private void InitializeComponent()
{
Text = $"Dubletten zusammenfuehren - {_profile.Name}";
Size = new Size(640, 500);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
MinimizeBox = false;
_lblInfo = new Label
{
Text = $"Profil: {_profile.Name} ({_profile.Connection.System} <-> {_profile.OutlookFolderName})",
Left = 12, Top = 12, Width = 600, Height = 20
};
_lblSummary = new Label
{
Text = "Analysiere...",
Left = 12, Top = 36, Width = 600, Height = 40, ForeColor = Color.DimGray
};
_chkDetail = new CheckBox
{
Text = "Detailvorschau anzeigen (welche Kontakte zusammengefuehrt werden)",
Left = 12, Top = 80, AutoSize = true, Enabled = false
};
_chkDetail.CheckedChanged += (s, e) => { if (_chkDetail.Checked) RenderDetail(); };
_txtLog = new TextBox
{
Left = 12, Top = 108, Width = 604, Height = 300,
Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false,
BackColor = Color.FromArgb(30, 30, 30), ForeColor = Color.FromArgb(212, 212, 212),
Font = new Font("Consolas", 9),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom
};
_btnStart = new Button
{
Text = "Zusammenfuehren starten", Left = 12, Top = 420, Width = 190, Height = 30,
Enabled = false, Anchor = AnchorStyles.Bottom | AnchorStyles.Left
};
_btnStart.Click += async (s, e) => await Execute();
_btnClose = new Button
{
Text = "Schliessen", Left = 526, Top = 420, Width = 90, Height = 30,
DialogResult = DialogResult.Cancel, Anchor = AnchorStyles.Bottom | AnchorStyles.Right
};
Controls.AddRange(new Control[] { _lblInfo, _lblSummary, _chkDetail, _txtLog, _btnStart, _btnClose });
CancelButton = _btnClose;
}
private void AppendLog(string message)
{
if (InvokeRequired) { Invoke(new Action(() => AppendLog(message))); return; }
_txtLog.AppendText(message + "\r\n");
}
private async Task Analyze()
{
_busy = true;
_btnStart.Enabled = false;
try
{
_plan = await Task.Run(() => _dedupe.AnalyzeAsync(_profile));
var s = $"Telefonanlage: {_plan.RemoteGroups.Count} Gruppe(n) mit Dubletten, {_plan.RemoteDeletions} Kontakt(e) werden zusammengefuehrt.\r\n" +
$"Outlook: {_plan.OutlookGroups.Count} Gruppe(n) mit Dubletten, {_plan.OutlookDeletions} Kontakt(e) werden zusammengefuehrt.";
_lblSummary.Text = _plan.HasWork ? s : "Keine Dubletten gefunden. Alles sauber. ✓";
_lblSummary.ForeColor = _plan.HasWork ? Color.OrangeRed : Color.Green;
_chkDetail.Enabled = _plan.HasWork;
_btnStart.Enabled = _plan.HasWork;
if (_chkDetail.Checked) RenderDetail();
}
catch (Exception ex)
{
_lblSummary.Text = "Analyse fehlgeschlagen: " + ex.Message;
_lblSummary.ForeColor = Color.Red;
AppendLog("FEHLER: " + ex.Message);
}
finally { _busy = false; }
}
private void RenderDetail()
{
if (_plan == null) return;
AppendLog("");
AppendLog("=== Vorschau: Telefonanlage ===");
foreach (var g in _plan.RemoteGroups)
AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)");
AppendLog("=== Vorschau: Outlook ===");
foreach (var g in _plan.OutlookGroups)
AppendLog($" Behalten: '{g.Survivor.DisplayName}' <= {g.Duplicates.Count} Dublette(n)");
AppendLog("");
}
private async Task Execute()
{
if (_busy || _plan == null || !_plan.HasWork) return;
var confirm = MessageBox.Show(this,
$"Jetzt zusammenfuehren?\n\n" +
$"Telefonanlage: {_plan.RemoteDeletions} Dublette(n) werden geloescht.\n" +
$"Outlook: {_plan.OutlookDeletions} Dublette(n) werden geloescht.\n\n" +
$"Die Daten der Dubletten werden vorher in den behaltenen Kontakt uebernommen (leere Felder aufgefuellt).\n" +
$"Danach bitte einmal synchronisieren.",
"Dubletten zusammenfuehren", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (confirm != DialogResult.Yes) return;
_busy = true;
_btnStart.Enabled = false;
_btnClose.Enabled = false;
AppendLog("Starte Zusammenfuehrung...");
Logger.Log($"Dubletten-Zusammenfuehrung gestartet: Profil '{_profile.Name}'");
try
{
DedupeService.DedupeReport report = null;
bool ran = await _coordinator.RunExclusiveAsync(
async () => { report = await Task.Run(() => _dedupe.ExecuteAsync(_profile)); },
status: AppendLog);
if (!ran)
{
AppendLog("Abgebrochen (Sync/Aktion laeuft oder anderer Arbeitsplatz aktiv).");
}
else if (report != null)
{
var summary = $"Fertig. Anlage: {report.RemoteMerged} Gruppe(n), {report.RemoteDeleted} geloescht. " +
$"Outlook: {report.OutlookMerged} Gruppe(n), {report.OutlookDeleted} geloescht. " +
$"Fehler: {report.Errors}.";
AppendLog(summary);
foreach (var m in report.Messages) AppendLog(" " + m);
AppendLog("Bitte jetzt einmal synchronisieren, damit die Zuordnungen sauber neu aufgebaut werden.");
Logger.Log("Dubletten-Zusammenfuehrung: " + summary);
foreach (var m in report.Messages) Logger.Log(" " + m);
_lblSummary.Text = summary + " -> Jetzt synchronisieren.";
_lblSummary.ForeColor = report.Errors > 0 ? Color.OrangeRed : Color.Green;
}
}
catch (Exception ex)
{
AppendLog("FEHLER: " + ex.Message);
Logger.Log($"Dubletten-Zusammenfuehrung FEHLER '{_profile.Name}': {ex.Message}");
}
finally
{
_busy = false;
_btnClose.Enabled = true;
}
}
}
}
@@ -0,0 +1,92 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
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()
{
Logger.PruneOlderThan(UserSettings.Load().LogRetentionDays);
_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 { }
}
}
}
+181 -28
View File
@@ -15,14 +15,19 @@ namespace StarfaceOutlookSync.UI
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly SyncEngine _syncEngine = new SyncEngine();
private readonly SyncCoordinator _coordinator = new SyncCoordinator();
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
// Aktive Benachrichtigungs-Einstellung (pro Sync-Lauf gesetzt).
private bool _notifyGeneral = true;
private bool _notifyWarn = true;
public MainForm()
{
InitializeComponent();
@@ -30,14 +35,34 @@ namespace StarfaceOutlookSync.UI
SetupAutoSync();
RefreshProfileList();
// Minimiert starten falls in Einstellungen aktiviert
// Einstellungen laden und anwenden
var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
// Protokoll bei Bedarf auf das eingestellte Alter eindampfen.
Logger.PruneOlderThan(settings.LogRetentionDays);
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 +83,11 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(620, 450);
MinimumSize = new Size(500, 350);
Size = new Size(930, 450);
MinimumSize = new Size(930, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
// Profil-Liste
_profileList = new ListView
@@ -97,16 +123,25 @@ 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 };
_btnDedupe = new Button { Text = "Dubletten", Width = 85, Height = 30 };
_btnDedupe.Click += (s, e) => ShowDedupe();
_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, _btnDedupe, _btnReset, _btnSettings, _btnLog, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
@@ -121,10 +156,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 +189,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()
@@ -202,7 +244,7 @@ namespace StarfaceOutlookSync.UI
var item = new ListViewItem(new[]
{
p.Name,
$"{p.StarfaceConnection.Host} / {p.StarfaceAddressBook.Name}",
$"{p.Connection.Host} / {p.AddressBook.Name}",
p.OutlookFolderName,
dirText,
lastSync
@@ -216,7 +258,7 @@ namespace StarfaceOutlookSync.UI
}
// Tray-Menu aktualisieren
SetupTrayIcon();
UpdateTrayMenu();
}
private void NewProfile()
@@ -255,6 +297,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,30 +350,85 @@ namespace StarfaceOutlookSync.UI
private async Task RunSync(SyncProfile profile)
{
var settings = UserSettings.Load();
_notifyGeneral = settings.NotificationsEnabled;
_notifyWarn = settings.NotifyWarningsErrors;
try
{
SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
// Lokaler Guard, Lock-Datei, Konflikt-Notizen und Protokoll laufen
// zentral im Coordinator (gemeinsam mit dem manuellen Sync-Pfad).
var outcome = await _coordinator.RunAsync(
profile,
runEngine: () => _syncEngine.SyncProfileAsync(profile),
status: SetStatus);
var result = await _syncEngine.SyncProfileAsync(profile);
if (outcome.Skipped)
{
SetStatus(outcome.SkipReason);
return;
}
// Konflikt-Hinweise anderer Arbeitsplaetze als Tray-Meldung (falls aktiv).
ShowRemoteNoticesTray(outcome.RemoteNotices);
if (outcome.Error != null)
{
Balloon(3000, "Starface Sync Fehler", outcome.Error.Message, ToolTipIcon.Error, warn: true);
SetStatus($"Fehler: {outcome.Error.Message}");
return;
}
var result = outcome.Result;
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);
// Zusammenfassung als Warnung werten, wenn Fehler/Konflikte auftraten.
bool noteworthy = result.Errors > 0 || result.Conflicts.Count > 0;
Balloon(3000, "Starface Sync", msg,
result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info, warn: noteworthy);
// Echte Feld-Konflikte gesondert melden (eigener Wert wurde ueberschrieben).
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";
Balloon(10000, $"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning, warn: true);
}
SetStatus(msg);
}
catch (Exception ex)
{
_trayIcon.ShowBalloonTip(3000, "Starface Sync Fehler",
ex.Message, ToolTipIcon.Error);
Balloon(3000, "Starface Sync Fehler", ex.Message, ToolTipIcon.Error, warn: true);
SetStatus($"Fehler: {ex.Message}");
Logger.Log($"Sync FEHLER '{profile.Name}': {ex.Message}");
}
}
/// <summary>Zeigt Konflikt-Hinweise anderer Arbeitsplaetze als Tray-Meldung.</summary>
private void ShowRemoteNoticesTray(System.Collections.Generic.List<ConflictNotice> notices)
{
if (notices == null || notices.Count == 0) return;
var detail = string.Join("\n", notices.Take(5).Select(p => p.ToString()));
if (notices.Count > 5)
detail += $"\n... und {notices.Count - 5} weitere";
Balloon(10000, $"Konflikt an anderem Arbeitsplatz ({notices.Count})", detail, ToolTipIcon.Warning, warn: true);
}
/// <summary>
/// Zeigt eine Tray-Meldung - aber nur, wenn die passende Benachrichtigungs-
/// Einstellung aktiv ist. warn=true -> Warnungen/Fehler (Konflikte, Fehler),
/// warn=false -> allgemeine Info-Meldungen.
/// </summary>
private void Balloon(int ms, string title, string text, ToolTipIcon icon, bool warn)
{
bool allow = warn ? _notifyWarn : _notifyGeneral;
if (allow) _trayIcon.ShowBalloonTip(ms, title, text, icon);
}
private void SetStatus(string text)
{
if (InvokeRequired)
@@ -326,6 +453,32 @@ namespace StarfaceOutlookSync.UI
}
}
private void ShowLog()
{
using (var log = new LogViewerForm())
{
log.ShowDialog(this);
}
}
private void ShowDedupe()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte zuerst ein Profil auswaehlen.", "Dubletten",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
using (var form = new DedupeForm(profile))
{
form.ShowDialog(this);
}
RefreshProfileList();
}
private void ExitApplication()
{
_autoSyncTimer?.Stop();
+106 -21
View File
@@ -22,11 +22,32 @@ 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<RemoteAddressBook> _addressBooks = new List<RemoteAddressBook>();
private List<string> _outlookFolderPaths = new List<string>();
private string _selectedOutlookPath = "";
private string _selectedOutlookName = "";
private ComboBox _cmbSystem;
private Label _lblAnlage;
private Label _lblLoginId;
private Label _lblAddressBook;
private string AnlagenName => GewaehlteAnlage == PhoneSystem.Fonaria ? "Fonaria" : "Starface";
private PhoneSystem GewaehlteAnlage =>
_cmbSystem != null && _cmbSystem.SelectedIndex == 1 ? PhoneSystem.Fonaria : PhoneSystem.Starface;
/// <summary>Beschriftungen, die je nach Anlage anders heissen.</summary>
private void UpdateSystemLabels()
{
var fonaria = GewaehlteAnlage == PhoneSystem.Fonaria;
if (_lblLoginId != null)
_lblLoginId.Text = fonaria ? "Benutzername:" : "Login-ID:";
if (_lblAddressBook != null)
_lblAddressBook.Text = fonaria ? "Fonaria-Adressbuch:" : "Starface-Adressbuch:";
UpdateDirectionHint();
}
public ProfileEditorForm(SyncProfile profile)
{
@@ -53,8 +74,26 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Profilname:", 12, y)); y += 22;
_txtName = new TextBox { Left = 12, Top = y, Width = 420 }; panel.Controls.Add(_txtName); y += 32;
// === Starface ===
panel.Controls.Add(MakeSectionLabel("Starface-Verbindung", 12, y)); y += 26;
// === Anlage ===
_lblAnlage = MakeSectionLabel("Verbindung zur Telefonanlage", 12, y); y += 26;
panel.Controls.Add(_lblAnlage);
panel.Controls.Add(MakeLabel("Anlage:", 12, y)); y += 22;
_cmbSystem = new ComboBox { Left = 12, Top = y, Width = 200, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbSystem.Items.AddRange(new object[] { "Starface", "Fonaria" });
_cmbSystem.SelectedIndex = 0;
// Beide sprechen ihre eigene Schnittstelle; der Vorgabeport ist
// derselbe, aber die Adressbuecher heissen anders. Beim Wechsel
// muss die Auswahl darum neu geladen werden.
_cmbSystem.SelectedIndexChanged += (s, e) =>
{
_addressBooks.Clear();
_cmbAddressBook.Items.Clear();
_lblTestResult.Text = "Anlage gewechselt - bitte Adressbuecher neu laden.";
_lblTestResult.ForeColor = Color.FromArgb(150, 80, 0);
UpdateSystemLabels();
};
panel.Controls.Add(_cmbSystem); y += 32;
panel.Controls.Add(MakeLabel("Host / IP-Adresse:", 12, y)); y += 22;
_txtHost = new TextBox { Left = 12, Top = y, Width = 320 };
@@ -66,7 +105,8 @@ namespace StarfaceOutlookSync.UI
_chkSsl = new CheckBox { Text = "HTTPS verwenden", Left = 12, Top = y, Checked = true, AutoSize = true };
panel.Controls.Add(_chkSsl); y += 28;
panel.Controls.Add(MakeLabel("Login-ID:", 12, y)); y += 22;
_lblLoginId = MakeLabel("Login-ID:", 12, y);
panel.Controls.Add(_lblLoginId); y += 22;
_txtLoginId = new TextBox { Left = 12, Top = y, Width = 200 }; panel.Controls.Add(_txtLoginId); y += 32;
panel.Controls.Add(MakeLabel("Kennwort:", 12, y)); y += 22;
@@ -83,7 +123,8 @@ namespace StarfaceOutlookSync.UI
_lblTestResult = new Label { Left = 12, Top = y, Width = 420, Height = 20, ForeColor = Color.Gray };
panel.Controls.Add(_lblTestResult); y += 26;
panel.Controls.Add(MakeLabel("Starface-Adressbuch:", 12, y)); y += 22;
_lblAddressBook = MakeLabel("Adressbuch der Anlage:", 12, y);
panel.Controls.Add(_lblAddressBook); y += 22;
_cmbAddressBook = new ComboBox { Left = 12, Top = y, Width = 420, DropDownStyle = ComboBoxStyle.DropDownList };
panel.Controls.Add(_cmbAddressBook); y += 32;
@@ -99,9 +140,20 @@ namespace StarfaceOutlookSync.UI
panel.Controls.Add(MakeLabel("Sync-Richtung:", 12, y)); y += 22;
_cmbDirection = new ComboBox { Left = 12, Top = y, Width = 250, DropDownStyle = ComboBoxStyle.DropDownList };
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Starface", "Starface -> Outlook" });
_cmbDirection.Items.AddRange(new object[] { "Bidirektional", "Outlook -> Anlage", "Anlage -> 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 +174,22 @@ namespace StarfaceOutlookSync.UI
CancelButton = _btnCancel;
}
private void UpdateDirectionHint()
{
switch (_cmbDirection.SelectedIndex)
{
case 1: // Outlook -> Anlage
_lblDirectionHint.Text = $"Achtung: Das Adressbuch der {AnlagenName} wird zur exakten Kopie von Outlook.\nKontakte, die nur dort existieren, werden geloescht.";
break;
case 2: // Anlage -> Outlook
_lblDirectionHint.Text = $"Achtung: Der Outlook-Ordner wird zur exakten Kopie der {AnlagenName}.\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 };
@@ -154,15 +222,17 @@ namespace StarfaceOutlookSync.UI
if (_existingProfile != null)
{
_txtName.Text = _existingProfile.Name;
_txtHost.Text = _existingProfile.StarfaceConnection.Host;
_txtPort.Text = _existingProfile.StarfaceConnection.Port.ToString();
_chkSsl.Checked = _existingProfile.StarfaceConnection.UseSsl;
_txtLoginId.Text = _existingProfile.StarfaceConnection.LoginId;
_txtPassword.Text = _existingProfile.StarfaceConnection.Password;
_cmbSystem.SelectedIndex = _existingProfile.Connection.System == PhoneSystem.Fonaria ? 1 : 0;
_txtHost.Text = _existingProfile.Connection.Host;
_txtPort.Text = _existingProfile.Connection.Port.ToString();
_chkSsl.Checked = _existingProfile.Connection.UseSsl;
_txtLoginId.Text = _existingProfile.Connection.LoginId;
_txtPassword.Text = _existingProfile.Connection.Password;
_chkEnabled.Checked = _existingProfile.Enabled;
_numAutoSync.Value = _existingProfile.AutoSyncIntervalMinutes;
_cmbDirection.SelectedIndex = (int)_existingProfile.SyncDirection;
UpdateSystemLabels();
// Outlook-Ordner
_selectedOutlookPath = _existingProfile.OutlookFolderPath;
@@ -170,10 +240,10 @@ namespace StarfaceOutlookSync.UI
_txtOutlookFolder.Text = _selectedOutlookPath;
// Adressbuch
if (_existingProfile.StarfaceAddressBook != null)
if (_existingProfile.AddressBook != null)
{
_addressBooks.Add(_existingProfile.StarfaceAddressBook);
_cmbAddressBook.Items.Add(_existingProfile.StarfaceAddressBook.Name);
_addressBooks.Add(_existingProfile.AddressBook);
_cmbAddressBook.Items.Add(_existingProfile.AddressBook.Name);
_cmbAddressBook.SelectedIndex = 0;
}
}
@@ -199,10 +269,11 @@ namespace StarfaceOutlookSync.UI
}
}
private StarfaceConnection GetConnection()
private SystemConnection GetConnection()
{
return new StarfaceConnection
return new SystemConnection
{
System = GewaehlteAnlage,
Host = _txtHost.Text.Trim(),
Port = int.TryParse(_txtPort.Text, out var p) ? p : 443,
UseSsl = _chkSsl.Checked,
@@ -219,7 +290,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (ok)
@@ -252,7 +323,7 @@ namespace StarfaceOutlookSync.UI
try
{
using (var client = new StarfaceApiClient(GetConnection()))
using (var client = ContactBackendFactory.Create(GetConnection()))
{
var ok = await client.LoginAsync();
if (!ok)
@@ -322,8 +393,8 @@ namespace StarfaceOutlookSync.UI
{
Id = _existingProfile?.Id ?? _pm.GenerateId(),
Name = _txtName.Text.Trim(),
StarfaceConnection = GetConnection(),
StarfaceAddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
Connection = GetConnection(),
AddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
OutlookFolderPath = _selectedOutlookPath,
OutlookFolderName = _selectedOutlookName,
SyncDirection = (SyncDirection)_cmbDirection.SelectedIndex,
@@ -333,9 +404,23 @@ namespace StarfaceOutlookSync.UI
};
if (_isNew)
{
_pm.AddProfile(profile);
}
else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.AddressBook?.TagId != profile.AddressBook?.TagId
|| _existingProfile.AddressBook?.Type != profile.AddressBook?.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();
+151 -5
View File
@@ -1,12 +1,18 @@
using System.Drawing;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
public class SettingsForm : Form
{
private CheckBox _chkStartMinimized;
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private CheckBox _chkNotifGeneral, _chkNotifWarn;
private CheckBox _chkAutostartUser, _chkAutostartAll;
private NumericUpDown _numLogRetention;
private TextBox _txtSharedDir;
private Button _btnBrowseShared;
private Button _btnSave, _btnCancel;
private readonly UserSettings _settings;
@@ -19,7 +25,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,28 +39,168 @@ 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)
};
_chkNotifGeneral = new CheckBox
{
Text = "Tray-Benachrichtigungen (allgemein)",
Left = 20, Top = 144, AutoSize = true,
Checked = _settings.NotificationsEnabled
};
_chkNotifWarn = new CheckBox
{
Text = "Benachrichtigungen bei Warnungen/Fehlern (Konflikte, Fehler)",
Left = 20, Top = 170, AutoSize = true,
Checked = _settings.NotifyWarningsErrors
};
var lblLogRetention = new Label
{
Text = "Protokoll auto-leeren - Eintraege aelter als (Tage, 0 = aus):",
Left = 20, Top = 204, AutoSize = true
};
_numLogRetention = new NumericUpDown
{
Left = 305, Top = 200, Width = 55, Minimum = 0, Maximum = 3650,
Value = System.Math.Max(0, System.Math.Min(3650, _settings.LogRetentionDays))
};
var lblShared = new Label
{
Text = "Gemeinsames Verzeichnis fuer Sync-Sperre (Mehrplatz, optional):",
Left = 20, Top = 240, AutoSize = true
};
_txtSharedDir = new TextBox
{
Left = 20, Top = 262, Width = 250,
Text = _settings.SharedDirectory
};
_btnBrowseShared = new Button
{
Text = "...", Left = 274, Top = 261, 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 = 288, Width = 330, Height = 32,
ForeColor = Color.Gray, Font = new Font("Segoe UI", 8)
};
// === Autostart ===
bool isAdmin = AutostartManager.IsAdmin();
var lblAutostart = new Label
{
Text = "Autostart (bei Windows-Anmeldung starten)",
Left = 12, Top = 332, AutoSize = true, Font = new Font("Segoe UI", 10, FontStyle.Bold)
};
_chkAutostartUser = new CheckBox
{
Text = "Nur fuer diesen Benutzer",
Left = 20, Top = 360, AutoSize = true,
Checked = AutostartManager.GetUserAutostart()
};
_chkAutostartAll = new CheckBox
{
Text = "Fuer alle Benutzer" + (isAdmin ? "" : " (nur mit Admin-Rechten aenderbar)"),
Left = 20, Top = 386, AutoSize = true,
Checked = AutostartManager.GetMachineAutostart(),
Enabled = isAdmin
};
_btnSave = new Button
{
Text = "Speichern", Left = 80, Top = 100, Width = 85, Height = 28,
Text = "Speichern", Left = 95, Top = 422, 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 = 422, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel
};
Controls.AddRange(new Control[] { _chkStartMinimized, _btnSave, _btnCancel });
Size = new Size(380, 512);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint,
_chkNotifGeneral, _chkNotifWarn, lblLogRetention, _numLogRetention,
lblShared, _txtSharedDir, _btnBrowseShared, lblSharedHint,
lblAutostart, _chkAutostartUser, _chkAutostartAll, _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.NotificationsEnabled = _chkNotifGeneral.Checked;
_settings.NotifyWarningsErrors = _chkNotifWarn.Checked;
_settings.LogRetentionDays = (int)_numLogRetention.Value;
_settings.SharedDirectory = _txtSharedDir.Text.Trim();
_settings.Save();
// Sofort anwenden, damit der Effekt direkt sichtbar ist.
Logger.PruneOlderThan(_settings.LogRetentionDays);
// Autostart (Registry). Pro Benutzer immer; alle Benutzer nur mit Admin.
AutostartManager.SetUserAutostart(_chkAutostartUser.Checked);
if (AutostartManager.IsAdmin())
{
if (!AutostartManager.SetMachineAutostart(_chkAutostartAll.Checked))
MessageBox.Show(this,
"Autostart fuer alle Benutzer konnte nicht geaendert werden (Rechte?).",
"Autostart", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
DialogResult = DialogResult.OK;
Close();
}
+39 -17
View File
@@ -11,6 +11,7 @@ namespace StarfaceOutlookSync.UI
{
private readonly SyncProfile _profile;
private readonly SyncEngine _engine = new SyncEngine();
private readonly SyncCoordinator _coordinator = new SyncCoordinator();
private TextBox _txtLog;
private ProgressBar _progressBar;
private Button _btnClose, _btnStart;
@@ -34,14 +35,15 @@ namespace StarfaceOutlookSync.UI
var infoLabel = new Label
{
Text = $"{_profile.StarfaceConnection.Host} ({_profile.StarfaceAddressBook.Name}) <-> {_profile.OutlookFolderName}",
Text = $"{_profile.Connection.Host} ({_profile.AddressBook.Name}) <-> {_profile.OutlookFolderName}",
Left = 12, Top = 12, Width = 460, AutoSize = false, Height = 20
};
_progressBar = new ProgressBar
{
Left = 12, Top = 38, Width = 460, Height = 22,
Style = ProgressBarStyle.Marquee
Style = ProgressBarStyle.Blocks,
Value = 0
};
_txtLog = new TextBox
@@ -91,19 +93,46 @@ namespace StarfaceOutlookSync.UI
_progressBar.Style = ProgressBarStyle.Marquee;
_lblResult.Text = "";
_engine.OnProgress += AppendLog;
try
// Engine-Aufruf inkl. Live-Log: laeuft im Hintergrund, damit das
// Fenster reagiert. Guard/Lock/Notizen/Protokoll uebernimmt der
// Coordinator (gemeinsam mit dem Auto-/Tray-Sync).
Func<Task<SyncResult>> runEngine = async () =>
{
var result = await Task.Run(() => _engine.SyncProfileAsync(_profile));
_engine.OnProgress += AppendLog;
try { return await Task.Run(() => _engine.SyncProfileAsync(_profile)); }
finally { _engine.OnProgress -= AppendLog; }
};
_progressBar.Style = ProgressBarStyle.Blocks;
_progressBar.Value = 100;
var outcome = await _coordinator.RunAsync(_profile, runEngine, status: AppendLog);
var resultText = $"Erstellt: {result.Created} | Aktualisiert: {result.Updated} | Fehler: {result.Errors}";
_lblResult.Text = resultText;
_progressBar.Style = ProgressBarStyle.Blocks;
_progressBar.Value = 100;
if (outcome.Skipped)
{
_lblResult.Text = outcome.SkipReason;
_lblResult.ForeColor = Color.OrangeRed;
AppendLog(outcome.SkipReason);
}
else if (outcome.Error != null)
{
_lblResult.Text = $"Fehler: {outcome.Error.Message}";
_lblResult.ForeColor = Color.Red;
AppendLog($"FEHLER: {outcome.Error.Message}");
}
else
{
var result = outcome.Result;
_lblResult.Text = $"Erstellt: {result.Created} | Aktualisiert: {result.Updated} | Fehler: {result.Errors}";
_lblResult.ForeColor = result.Errors > 0 ? Color.OrangeRed : Color.Green;
// Konflikt-Hinweise anderer Arbeitsplaetze im Fenster zeigen.
foreach (var n in outcome.RemoteNotices)
AppendLog($"Konflikt an anderem Arbeitsplatz: {n}");
foreach (var c in result.Conflicts)
AppendLog($"KONFLIKT: {c}");
if (result.ErrorMessages.Count > 0)
{
AppendLog("--- Fehler ---");
@@ -111,14 +140,7 @@ namespace StarfaceOutlookSync.UI
AppendLog(err);
}
}
catch (Exception ex)
{
_lblResult.Text = $"Fehler: {ex.Message}";
_lblResult.ForeColor = Color.Red;
AppendLog($"FEHLER: {ex.Message}");
}
_engine.OnProgress -= AppendLog;
_btnStart.Enabled = true;
_btnStart.Text = "Erneut synchronisieren";
_btnClose.Enabled = true;