Compare commits

...

63 Commits

Author SHA1 Message Date
duffyduck df13ddf6b1 Release v0.0.0.22 2026-04-03 19:38:02 +02:00
duffyduck c08a625348 Write Outlook security keys to both Policies and normal user path
On Terminal Servers, normal users cannot write to HKCU\Software\
Policies. Now also writes to HKCU\Software\Microsoft\Office\...\
Security which is always writable and also read by Outlook.

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

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

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

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

Outlook must be restarted after changing this setting.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:50:16 +02:00
duffyduck 3c38a1a6cc Release v0.0.0.10 2026-04-03 12:17:30 +02:00
duffyduck ba0b79de64 Release v0.0.0.9 2026-04-03 12:14:42 +02:00
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 499763d81f Release v0.0.0.6 2026-04-03 11:57:58 +02:00
duffyduck 9aa6ccb224 Fix Outlook COM connection: use Activator and clear error message
- Try GetActiveObject first, then Activator.CreateInstance as fallback
- Use Type.GetTypeFromProgID for version-independent COM activation
- Clear error message explaining possible causes including New Outlook
- NuGet Interop v15 is fine - it's the assembly version, works with
  all Outlook versions (2013-2024) via COM interface compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:56:34 +02:00
duffyduck 235960f450 Release v0.0.0.5 2026-04-03 11:53:31 +02:00
duffyduck 9298c3c287 Fix Outlook folder discovery to find all contact folders
- Scan all stores recursively instead of only the default folder
- Properly release COM objects to avoid leaks
- Better error handling with debug output per store/folder
- Show error message if Outlook is not reachable
- Fallback to default contacts folder if recursive scan finds nothing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:52:44 +02:00
duffyduck d0a2ffc9fd Release v0.0.0.4 2026-04-03 11:49:17 +02:00
duffyduck 7ac5ff24a2 Release v0.0.0.3 2026-04-03 11:48:36 +02:00
duffyduck cbe1a8f452 Release v0.0.0.3 2026-04-03 11:11:52 +02:00
duffyduck 6627212032 Release v0.0.0.3 2026-04-03 11:08:03 +02:00
duffyduck 1aa74ec014 Add Outlook folder browser and user settings
- Replace Outlook folder ComboBox with TextBox + Browse button
  that opens a TreeView-based folder browser dialog
- Add per-user settings stored in %AppData% (not HKLM)
  with option to start minimized (System Tray only)
- Add Settings button to main window
- Settings are per-user, independent of profiles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:04:57 +02:00
duffyduck 41c690f8c2 Release v0.0.0.2 2026-04-03 10:54:58 +02:00
duffyduck 50240ee0f3 Change autostart to HKLM so it applies to all users
Important for Terminal Server / multi-user environments where
the setup is run once by an admin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:54:05 +02:00
duffyduck d3b28c0dcc Release v0.0.0.1 2026-04-03 10:52:54 +02:00
duffyduck abd52b351a Fix Inno Setup compile error and release script sed pattern
- Declare ErrorCode variable in InitializeSetup function
- Remove orphan var block at end of Code section
- Fix sed pattern in release.sh to only match version inside quotes
- Restore AboutForm.cs variable name damaged by previous sed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:21 +02:00
duffyduck 8ac4c12b46 Add sudo to docker command in release script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:48:12 +02:00
duffyduck 574c1923f9 Add release script documentation to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:45:15 +02:00
duffyduck b1658c8d3c Use basic auth instead of token for Gitea release upload
Prompts for username and password at script start instead
of requiring a pre-configured API token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:41:58 +02:00
duffyduck 6a9a73106d Add release script for automated build and Gitea upload
Handles version bump, build, Inno Setup via Docker, git tag,
push, Gitea release creation and setup.exe upload in one step.

Usage: ./release.sh 0.1.0.0 "Release description"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:40:32 +02:00
duffyduck ec51dc8fc6 Restructure README: Docker as preferred installer build method
Reorder installer section with Docker first (recommended),
Windows second, Wine as fallback. Add one-liner build command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:37:48 +02:00
duffyduck a831ec6f81 Update README with Linux build instructions
Add .NET SDK install, build and Inno Setup instructions for
both Windows and Linux (Docker or Wine).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:34:52 +02:00
duffyduck ad649ad319 Switch to .NET 8 for cross-platform build support
- Target net8.0-windows instead of net4.8
- EnableWindowsTargeting for Linux build
- Replace Marshal.GetActiveObject with P/Invoke (not in .NET 8)
- Use NuGet package for Outlook Interop instead of local DLL ref
- Update Inno Setup script for .NET 8 runtime check
- Builds successfully on Linux, runs on Windows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:33:21 +02:00
duffyduck 84ba78a1c5 Rewrite as standalone C# WinForms app
Replace the Office Web Add-in with a native Windows application.
The web add-in required Exchange/M365 for registration which is
not available in all customer environments (standalone Office,
POP/IMAP only).

The new app:
- Uses COM Interop to access Outlook contacts directly
- Communicates with Starface REST API (accepts self-signed certs)
- Runs as System Tray app with optional auto-sync
- Profile-based config stored in %AppData%
- No webserver, no certificates, no Exchange, no M365 needed
- Inno Setup installer for clean MSI-style deployment
- Works with any Outlook version (Classic and New)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:26:58 +02:00
duffyduck 8d7ae01ac3 Update .gitignore for C# standalone app
Replace Node.js ignores with .NET build output patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:26:39 +02:00
duffyduck 33707bdb44 Simplify add-in registration to browser sideload only
Remove Exchange PowerShell option. Outlook add-ins are registered
per Microsoft 365 account via browser sideload. Each user must
add the add-in once - it then works on all their devices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:13:56 +02:00
duffyduck 4bea737696 Fix Outlook add-in registration: use Exchange or manual sideload
The TrustedCatalogs registry approach only works for Word/Excel/
PowerPoint, NOT for Outlook. Outlook add-ins must be registered
via Exchange or manually through OWA.

Setup now offers two paths:
- Exchange Online PowerShell (New-App) for organizations
- Manual sideload via https://aka.ms/olksideload as fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:11:31 +02:00
57 changed files with 3650 additions and 10166 deletions
+8 -1
View File
@@ -1 +1,8 @@
node_modules/
bin/
obj/
dist/
*.user
*.suo
.vs/
packages/
*.nupkg
+179 -183
View File
@@ -1,250 +1,246 @@
# Starface Kontakt-Sync - Outlook Add-in
# Starface Kontakt-Sync
Outlook Add-in zur bidirektionalen Synchronisation von Kontakten zwischen Microsoft Outlook und einer Starface Telefonanlage.
Windows-Anwendung zur bidirektionalen Synchronisation von Kontakten zwischen Microsoft Outlook und einer Starface Telefonanlage.
## Funktionen
- **Bidirektionale Synchronisation** von Kontakten zwischen Outlook und Starface
- **Profil-System** zum Verwalten mehrerer Sync-Konfigurationen (z.B. verschiedene Adressbücher oder Anlagen)
- **Starface-Adressbücher**: Zentrales Adressbuch, persönliches Adressbuch und Tag-basierte Adressbücher
- **Outlook-Kontaktordner**: Frei wählbarer Kontaktordner als Sync-Ziel
- **Sync-Richtung** konfigurierbar: Outlook Starface, Starface Outlook oder bidirektional
- **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
- **Intelligentes Matching**: Kontakte werden anhand von E-Mail-Adresse oder Name abgeglichen
- **Änderungserkennung**: Nur geänderte Kontakte werden übertragen (Hash-basiert)
- **Kompatibel** mit Outlook Classic und dem neuen Outlook (Office Web Add-in)
- **Einfacher Installer** mit automatischer Zertifikatskonfiguration
- **Aenderungserkennung**: Nur geaenderte Kontakte werden uebertragen (Hash-basiert)
- **Auto-Sync**: Optionaler automatischer Sync in konfigurierbarem Intervall
- **System Tray**: Laeuft im Hintergrund, Schnell-Sync per Rechtsklick
- **Kompatibel** mit Outlook Classic und dem neuen Outlook
- **Komplett lokal**: Kein Exchange, kein Microsoft 365, kein Cloud-Konto erforderlich
## Voraussetzungen
- [Node.js](https://nodejs.org/) ab Version 18 (LTS empfohlen)
- npm (wird mit Node.js mitgeliefert)
- Microsoft Outlook (Classic oder Neu)
- Windows 10/11
- .NET 8 Desktop Runtime (Setup prueft und verlinkt die Download-Seite falls noetig)
- Microsoft Outlook (Classic oder Neu, beliebige Version)
- Starface Telefonanlage ab Version 6.7 (REST-API)
- Windows 10/11 (für den Installer)
## Installation beim Kunden
### Schnellinstallation (empfohlen)
### Setup ausfuehren
Der Installer richtet alles automatisch ein: Zertifikate, lokaler Webserver, Outlook-Registrierung.
**Schritt 1: Add-in bauen (einmalig, auf dem Entwickler-PC)**
```bash
npm install
npm run build
```
**Schritt 2: Installer-Paket zum Kunden mitnehmen**
Folgende Dateien/Ordner werden benötigt:
```
installer/
├── setup.ps1 # Installations-Script
├── uninstall.ps1 # Deinstallations-Script
├── local-server.js # Lokaler HTTPS-Webserver
dist/ # Build-Ausgabe (von Schritt 1)
```
**Schritt 3: Beim Kunden ausführen (als Administrator)**
```powershell
# PowerShell als Administrator öffnen, dann:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\installer\setup.ps1
```
Das Setup fragt interaktiv ab:
| Abfrage | Beispiel | Beschreibung |
|---------|----------|--------------|
| Starface Host/IP | `192.168.1.100` | IP-Adresse oder Hostname der Starface (nur für Zertifikat-Import) |
| Starface HTTPS-Port | `443` | Standard: 443 |
| Lokaler Port | `444` | Port für den lokalen Webserver (Standard: 444) |
> **Hinweis:** Login-Daten werden beim Setup nicht benötigt. Diese werden später im Add-in selbst in den Sync-Profilen konfiguriert.
### Was macht das Setup?
1. **Starface-Zertifikat importieren** - Verbindet sich zur Starface, extrahiert das SSL-Zertifikat und importiert es als vertrauenswürdig in den Windows-Zertifikatspeicher. Damit funktionieren die API-Aufrufe vom Add-in zur Starface.
2. **Lokale Zertifikate erstellen** - Erstellt eine lokale CA und ein davon signiertes localhost-Zertifikat. Die CA wird als vertrauenswürdig importiert. Damit läuft der lokale Webserver mit gültigem HTTPS.
3. **Lokalen Webserver einrichten** - Installiert einen minimalen Node.js HTTPS-Server als Windows Scheduled Task. Der Server startet automatisch beim Systemstart und liefert nur die statischen Add-in Dateien über `https://localhost:444` aus.
4. **Outlook Add-in registrieren** - Registriert das Add-in automatisch für Outlook Classic (per Registry). Für das neue Outlook wird eine Anleitung zur manuellen Einrichtung angezeigt.
1. `StarfaceOutlookSync_Setup_0.0.0.1.exe` ausfuehren
2. Installationsoptionen waehlen:
- Desktop-Verknuepfung erstellen (optional)
- Bei Windows-Anmeldung automatisch starten (empfohlen)
3. Fertig - die App startet automatisch
### Nach der Installation
- **Outlook Classic**: Outlook neu starten. Im Ribbon erscheint der Button **"Kontakt-Sync"** in der Gruppe "Starface Sync".
- **Neues Outlook**: Manuell hinzufügen über Einstellungen → Add-Ins verwalten → Benutzerdefinierte Add-Ins → Aus Datei hinzufügen → `C:\Program Files\StarfaceOutlookSync\manifest-catalog\manifest.xml`
1. App starten (Tray-Icon oder Desktop-Verknuepfung)
2. "Neues Profil" klicken
3. Starface-Verbindungsdaten eingeben (Host, Login-ID, Kennwort)
4. "Verbindung testen" und "Adressbuecher laden"
5. Starface-Adressbuch und Outlook-Kontaktordner waehlen
6. Speichern und "Jetzt synchronisieren"
### Deinstallation
```powershell
# PowerShell als Administrator:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\uninstall.ps1
# oder direkt aus dem Installationsverzeichnis:
& "C:\Program Files\StarfaceOutlookSync\uninstall.ps1"
```
Die Deinstallation entfernt:
- Den lokalen Webserver (Scheduled Task)
- Die lokale CA und das localhost-Zertifikat
- Die Outlook-Registrierung
- Alle installierten Dateien
Das Starface-Zertifikat wird **nicht** entfernt, da es ggf. von anderen Anwendungen benötigt wird.
### Weitere Starface-Anlagen hinzufügen
Wenn im Add-in ein Sync-Profil zu einer weiteren Starface-Anlage eingerichtet wird, deren Zertifikat noch nicht importiert ist, schlägt der Verbindungstest fehl. Das Add-in zeigt dann einen entsprechenden Hinweis an.
Um das Zertifikat einer weiteren Anlage zu importieren:
```powershell
# PowerShell als Administrator:
cd "C:\Program Files\StarfaceOutlookSync"
.\import-cert.ps1 -StarfaceHost 192.168.2.200
# oder mit anderem Port:
.\import-cert.ps1 -StarfaceHost pbx2.firma.local -Port 8443
```
Danach funktioniert die Verbindung im Add-in sofort - kein Neustart nötig.
Ueber Windows Einstellungen -> Apps oder die Systemsteuerung.
Benutzerdaten (Profile, Mappings) werden mit entfernt.
## Entwicklung
### Dependencies installieren
### Voraussetzungen (Build)
```bash
npm install
- .NET 8 SDK (laeuft auf Windows, Linux und macOS)
- Fuer den Installer: Inno Setup 6 (Windows) oder Docker (Linux)
### .NET SDK installieren
**Windows:**
```
winget install Microsoft.DotNet.SDK.8
```
### Development Server starten
**Debian/Ubuntu:**
```bash
npm run dev
# Ueber das offizielle Install-Script:
wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 8.0
export PATH="$HOME/.dotnet:$PATH"
```
Startet einen lokalen HTTPS-Server auf `https://localhost:3000` mit einem Dev-Zertifikat (nur für Entwicklung).
### Bauen
### Production Build
Das Projekt kann sowohl unter Windows als auch unter Linux gebaut werden.
Die fertige EXE laeuft nur auf Windows (WinForms + COM Interop).
```bash
npm run build
# Release-Build
dotnet build src/StarfaceOutlookSync/StarfaceOutlookSync.csproj -c Release
# Oder publish (mit allen Abhaengigkeiten):
dotnet publish src/StarfaceOutlookSync/StarfaceOutlookSync.csproj -c Release
```
Die fertigen Dateien liegen anschließend im Ordner `dist/`.
Build-Ausgabe: `src/StarfaceOutlookSync/bin/Release/net8.0-windows/win-x64/`
### Manuelles Sideloading (zum Testen)
### Installer erstellen
1. `npm run dev` starten
2. In Outlook: Datei → Add-Ins verwalten → Meine Add-Ins → "Benutzerdefiniertes Add-In hinzufügen" → `manifest.xml` auswählen
Die Setup-EXE wird mit Inno Setup 6 erstellt.
## Benutzung
**Linux (Docker - empfohlen):**
1. In Outlook auf den Button **"Kontakt-Sync"** in der Ribbon-Leiste klicken
2. Ein neues Sync-Profil anlegen:
- **Profilname** vergeben (z.B. "Firmenkontakte Hauptanlage")
- **Starface-Verbindung** konfigurieren: Host/IP, Port, Login-ID und Kennwort eingeben
- **Verbindung testen** um die Zugangsdaten zu prüfen
- **Adressbücher laden** um die verfügbaren Starface-Adressbücher abzurufen
- **Starface-Adressbuch** auswählen (Zentral, Persönlich oder Tag)
- **Outlook-Kontaktordner** auswählen
- **Synchronisationsrichtung** festlegen
3. Profil speichern
4. Über "Jetzt synchronisieren" die Kontakte abgleichen
Einfachster Weg unter Linux. Baut und packt alles in einem Schritt:
Es können beliebig viele Profile angelegt werden, auch für unterschiedliche Starface-Anlagen.
```bash
# 1. Release bauen
dotnet build src/StarfaceOutlookSync/StarfaceOutlookSync.csproj -c Release
# 2. Installer erstellen
docker run --rm -v "$PWD:/work" amake/innosetup installer/setup.iss
```
Die fertige Setup-EXE liegt danach in `dist/`.
Komplett als Einzeiler (Build + Installer):
```bash
dotnet build src/StarfaceOutlookSync/StarfaceOutlookSync.csproj -c Release && docker run --rm -v "$PWD:/work" amake/innosetup installer/setup.iss
```
**Windows:**
1. [Inno Setup 6](https://jrsoftware.org/isinfo.php) installieren
2. `installer/setup.iss` oeffnen und kompilieren
3. Setup-EXE wird in `dist/` erstellt
Oder per Kommandozeile:
```cmd
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\setup.iss
```
**Linux (Wine - Alternative falls kein Docker vorhanden):**
```bash
# Einmalig: Inno Setup in Wine installieren
sudo apt install wine
wine ~/Downloads/innosetup-6.x.exe
# Kompilieren
wine "$HOME/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe" installer/setup.iss
```
### Release erstellen
Das Release-Script automatisiert den kompletten Release-Prozess:
Versionsnummer aktualisieren, bauen, Installer erstellen, Git Tag + Push
und Gitea Release mit Setup-EXE als Download hochladen.
```bash
./release.sh <version> ["beschreibung"]
```
Beispiele:
```bash
# Release mit Standard-Beschreibung
./release.sh 0.1.0.0
# Release mit eigener Beschreibung
./release.sh 0.2.0.0 "Neues Feature: Auto-Sync"
```
Das Script fragt beim Start nach Gitea-Benutzername und Kennwort
und fuehrt dann folgende Schritte automatisch aus:
1. Prueft Voraussetzungen (dotnet, docker, curl, sauberes git)
2. Aktualisiert Versionsnummer in `.csproj`, `AboutForm.cs` und `setup.iss`
3. Baut das Projekt (`dotnet build -c Release`)
4. Erstellt den Installer via Docker (`amake/innosetup`)
5. Git Commit + Tag (`vX.X.X.X`)
6. Push zu Gitea (main + tag)
7. Erstellt Gitea Release mit Setup-EXE als Download-Anhang
Voraussetzungen:
- .NET 8 SDK
- Docker
- curl
- Gitea-Account mit Push-Rechten auf das Repository
## Projektstruktur
```
outlook-sync/
── manifest.xml # Office Add-in Manifest
├── package.json # Dependencies & Scripts
├── webpack.config.js # Build-Konfiguration
├── assets/ # Add-in Icons
├── dist/ # Build-Ausgabe
─ StarfaceOutlookSync.sln # Visual Studio Solution
├── installer/
── setup.ps1 # Installations-Script
│ ├── uninstall.ps1 # Deinstallations-Script
├── import-cert.ps1 # Zertifikat-Import für weitere Anlagen
── local-server.js # Lokaler HTTPS-Webserver
└── src/
├── models/
│ └── types.ts # Datenmodelle
├── services/
│ ├── starface-api.ts # Starface REST API Client
│ ├── outlook-contacts.ts # Outlook REST API Client
│ ├── sync-engine.ts # Sync-Logik
│ └── profile-manager.ts # Profilverwaltung
└── taskpane/
├── index.tsx # Entry Point
├── taskpane.html # HTML Template
├── styles/taskpane.css # Styling
└── components/
├── App.tsx # Hauptkomponente
├── ProfileList.tsx # Profilübersicht
├── ProfileEditor.tsx # Profil-Editor
└── SyncView.tsx # Sync-Ansicht
── setup.iss # Inno Setup Script
└── src/StarfaceOutlookSync/
├── StarfaceOutlookSync.csproj # Projektdatei
── Program.cs # Entry Point (Single Instance)
├── Models/
│ ├── UnifiedContact.cs # Kontakt-Datenmodell
│ └── SyncProfile.cs # Profile, Mappings, Ergebnisse
├── Services/
│ ├── StarfaceApiClient.cs # Starface REST-API Client
│ ├── OutlookContactsService.cs # Outlook COM Interop
│ ├── ProfileManager.cs # Profilverwaltung (AppData)
│ └── SyncEngine.cs # Bidirektionale Sync-Logik
└── UI/
├── MainForm.cs # Hauptfenster + System Tray
├── ProfileEditorForm.cs # Profil anlegen/bearbeiten
├── SyncProgressForm.cs # Sync-Fortschritt mit Log
└── AboutForm.cs # Info-Dialog
```
## Synchronisierte Kontaktfelder
| Feld | Outlook | Starface |
|------|---------|----------|
| Vorname | GivenName | NAME |
| Nachname | Surname | SURNAME |
| Feld | Outlook (COM) | Starface (REST) |
|------|---------------|-----------------|
| Vorname | FirstName | NAME |
| Nachname | LastName | SURNAME |
| Firma | CompanyName | COMPANY |
| Position | JobTitle | JOB_TITLE |
| E-Mail | EmailAddresses | EMAIL |
| Telefon (Büro) | Business Phone | OFFICE_PHONE_NUMBER |
| Mobiltelefon | Mobile Phone | MOBILE_PHONE_NUMBER |
| Telefon (Privat) | Home Phone | PRIVATE_PHONE_NUMBER |
| Fax | Business Fax | FAX_NUMBER |
| Straße | Street | STREET |
| Stadt | City | CITY |
| PLZ | PostalCode | POSTAL_CODE |
| Bundesland | State | STATE |
| Land | CountryOrRegion | COUNTRY |
| Webseite | Websites | URL |
| Notizen | PersonalNotes | NOTE |
| E-Mail | Email1Address | EMAIL |
| Telefon (Buero) | BusinessTelephoneNumber | OFFICE_PHONE_NUMBER |
| Mobiltelefon | MobileTelephoneNumber | MOBILE_PHONE_NUMBER |
| Telefon (Privat) | HomeTelephoneNumber | PRIVATE_PHONE_NUMBER |
| Fax | BusinessFaxNumber | FAX_NUMBER |
| Strasse | BusinessAddressStreet | STREET |
| Stadt | BusinessAddressCity | CITY |
| PLZ | BusinessAddressPostalCode | POSTAL_CODE |
| Bundesland | BusinessAddressState | STATE |
| Land | BusinessAddressCountry | COUNTRY |
| Webseite | WebPage | URL |
| Notizen | Body | NOTE |
| Anrede | Title | SALUTATION |
| Titel | — | TITLE |
| Geburtstag | Birthday | BIRTHDAY |
## Technische Details
### Architektur
Das Add-in ist ein Office Web Add-in (funktioniert in Classic und New Outlook). Outlook lädt das Add-in als Webseite in einem eingebetteten Browser (WebView). Die Webseite kommuniziert dann direkt mit der Starface REST-API und der Outlook REST-API:
Die Anwendung ist eine native Windows-App (WinForms, .NET Framework 4.8). Sie greift direkt per COM Interop auf Outlook-Kontakte zu und kommuniziert per REST-API mit der Starface. Kein Webserver, kein Browser, kein Exchange erforderlich.
```
Outlook
── WebView (eingebetteter Browser)
├── Lädt UI von: https://localhost:444 (lokaler Webserver)
├── Spricht direkt mit: Starface REST-API
└── Spricht direkt mit: Outlook REST-API (Office.js)
StarfaceOutlookSync.exe
── COM Interop -> Outlook Kontakte (lokal)
├── REST-API -> Starface Telefonanlage (HTTPS)
└── Dateisystem -> Profile & Mappings (%AppData%)
```
### Zertifikate
Der lokale Webserver und die Starface verwenden HTTPS. Da die Starface typischerweise ein self-signed Zertifikat hat und der lokale Webserver ebenfalls ein lokales Zertifikat braucht, erstellt das Setup:
- Importiert das **Starface-Zertifikat** in den Windows Trusted Root Store → Outlook/WebView vertraut den API-Aufrufen zur Starface
- Erstellt eine **lokale CA** → wird in den Trusted Root Store importiert
- Erstellt ein **localhost-Zertifikat**, signiert von der lokalen CA → der lokale Webserver hat gültiges HTTPS
### Datenspeicherung
- **Sync-Profile**: `localStorage` im Outlook WebView
- **Sync-Mappings**: `localStorage` im Outlook WebView (Zuordnung Outlook-ID ↔ Starface-ID)
- **Server-Konfiguration**: `C:\Program Files\StarfaceOutlookSync\config.json`
- **Zugangsdaten**: Werden nur im Add-in (localStorage) gespeichert, nicht auf dem Dateisystem
Alle Daten werden lokal pro Benutzer gespeichert:
- `%AppData%\StarfaceOutlookSync\profiles.json` - Sync-Profile mit Zugangsdaten
- `%AppData%\StarfaceOutlookSync\mappings\` - Kontakt-Zuordnungen pro Profil
### SSL-Zertifikate
Self-signed Zertifikate der Starface werden automatisch akzeptiert. Es ist kein manueller Zertifikat-Import erforderlich.
## Lizenz
Proprietär - Alle Rechte vorbehalten.
Proprietaer - Alle Rechte vorbehalten.
HackerSoft - Hacker-Net Telekommunikation
Stefan Hacker
Am Wunderburgpark 5b
26135 Oldenburg
+19
View File
@@ -0,0 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarfaceOutlookSync", "src\StarfaceOutlookSync\StarfaceOutlookSync.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

-209
View File
@@ -1,209 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
<Version>1.0.0.0</Version>
<ProviderName>Starface Outlook Sync</ProviderName>
<DefaultLocale>de-DE</DefaultLocale>
<DisplayName DefaultValue="Starface Kontakt-Sync" />
<Description DefaultValue="Synchronisiert Outlook-Kontakte mit Starface Telefonanlage" />
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png" />
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png" />
<SupportUrl DefaultValue="https://localhost:3000" />
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox" />
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1" />
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html" />
<RequestedHeight>450</RequestedHeight>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteMailbox</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" />
<Rule xsi:type="ItemIs" ItemType="Appointment" />
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="taskpaneUrl" />
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroup">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtn">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroupCompose">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtnCompose">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://localhost:3000/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://localhost:3000/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://localhost:3000/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://localhost:3000/taskpane.html" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="groupLabel" DefaultValue="Starface Sync" />
<bt:String id="taskpaneBtnLabel" DefaultValue="Kontakt-Sync" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="taskpaneBtnDesc" DefaultValue="Kontakte zwischen Outlook und Starface synchronisieren" />
</bt:LongStrings>
</Resources>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.5">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="taskpaneUrl" />
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroup2">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtn2">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroupCompose2">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtnCompose2">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://localhost:3000/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://localhost:3000/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://localhost:3000/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://localhost:3000/taskpane.html" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="groupLabel" DefaultValue="Starface Sync" />
<bt:String id="taskpaneBtnLabel" DefaultValue="Kontakt-Sync" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="taskpaneBtnDesc" DefaultValue="Kontakte zwischen Outlook und Starface synchronisieren" />
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
-2
View File
File diff suppressed because one or more lines are too long
-39
View File
@@ -1,39 +0,0 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
-1
View File
@@ -1 +0,0 @@
<!doctype html><html lang="de"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Starface Kontakt-Sync</title><script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script><script defer="defer" src="taskpane.bundle.js"></script></head><body><div id="root"></div></body></html>
-84
View File
@@ -1,84 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Importiert das SSL-Zertifikat einer Starface-Anlage in den Windows-Zertifikatspeicher.
.DESCRIPTION
Verbindet sich per SSL/TLS zur angegebenen Starface-Anlage, extrahiert das
Zertifikat und importiert es als vertrauenswuerdig. Danach kann das Outlook
Add-in die Starface REST-API ueber HTTPS erreichen.
.EXAMPLE
.\import-cert.ps1 -StarfaceHost 192.168.1.100
.EXAMPLE
.\import-cert.ps1 -StarfaceHost pbx.firma.local -Port 8443
#>
param(
[Parameter(Mandatory = $true)]
[string]$StarfaceHost,
[int]$Port = 443
)
$ErrorActionPreference = "Stop"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " Starface-Zertifikat importieren" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host " [OK] Verbinde mit ${StarfaceHost}:${Port} ..." -ForegroundColor Green
try {
$tcpClient = New-Object System.Net.Sockets.TcpClient
$tcpClient.Connect($StarfaceHost, $Port)
$sslStream = New-Object System.Net.Security.SslStream(
$tcpClient.GetStream(),
$false,
{ $true } # Alle Zertifikate akzeptieren fuer den Abruf
)
$sslStream.AuthenticateAsClient($StarfaceHost)
$remoteCert = $sslStream.RemoteCertificate
$x509Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($remoteCert)
$sslStream.Close()
$tcpClient.Close()
Write-Host " Zertifikat erhalten:" -ForegroundColor Green
Write-Host " Subject: $($x509Cert.Subject)" -ForegroundColor White
Write-Host " Aussteller: $($x509Cert.Issuer)" -ForegroundColor White
Write-Host " Gueltig bis: $($x509Cert.NotAfter)" -ForegroundColor White
Write-Host " Thumbprint: $($x509Cert.Thumbprint)" -ForegroundColor White
Write-Host ""
# Pruefen ob bereits vorhanden
$rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine
)
$rootStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
$existing = $rootStore.Certificates | Where-Object { $_.Thumbprint -eq $x509Cert.Thumbprint }
$rootStore.Close()
if ($existing) {
Write-Host " Zertifikat ist bereits als vertrauenswuerdig gespeichert." -ForegroundColor Yellow
} else {
$rootStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$rootStore.Add($x509Cert)
$rootStore.Close()
Write-Host " [OK] Zertifikat erfolgreich als vertrauenswuerdig importiert!" -ForegroundColor Green
}
} catch {
Write-Host " [X] Fehler: $_" -ForegroundColor Red
Write-Host ""
Write-Host " Moegliche Ursachen:" -ForegroundColor Yellow
Write-Host " - Starface nicht erreichbar (IP/Hostname pruefen)" -ForegroundColor Yellow
Write-Host " - Falscher Port (Standard: 443)" -ForegroundColor Yellow
Write-Host " - Firewall blockiert die Verbindung" -ForegroundColor Yellow
exit 1
}
Write-Host ""
Read-Host "Eingabetaste zum Beenden"
-148
View File
@@ -1,148 +0,0 @@
/**
* Minimaler lokaler HTTPS-Server für das Starface Outlook Sync Add-in.
* Liefert nur statische Dateien aus dem webroot-Verzeichnis aus.
* Wird als Windows-Dienst oder Scheduled Task ausgeführt.
*/
const https = require("https");
const http = require("http");
const fs = require("fs");
const path = require("path");
const url = require("url");
// Konfiguration aus config.json laden
const configPath = path.join(__dirname, "config.json");
if (!fs.existsSync(configPath)) {
console.error("config.json nicht gefunden:", configPath);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const PORT = config.port || 444;
const WEBROOT = path.join(__dirname, "webroot");
const CERT_DIR = path.join(__dirname, "certs");
// MIME-Types
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".xml": "application/xml; charset=utf-8",
};
function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
function handleRequest(req, res) {
// CORS-Header für Office Add-in
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (req.method !== "GET") {
res.writeHead(405, { "Content-Type": "text/plain" });
res.end("Method Not Allowed");
return;
}
// URL parsen und Pfad bereinigen
const parsedUrl = url.parse(req.url);
let filePath = decodeURIComponent(parsedUrl.pathname || "/");
// Directory index
if (filePath === "/" || filePath === "") {
filePath = "/taskpane.html";
}
// Pfad-Traversal verhindern
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, "");
const fullPath = path.join(WEBROOT, safePath);
// Sicherstellen, dass der Pfad innerhalb des webroot liegt
if (!fullPath.startsWith(WEBROOT)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
// Datei ausliefern
fs.stat(fullPath, (err, stats) => {
if (err || !stats.isFile()) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found: " + safePath);
return;
}
const mimeType = getMimeType(fullPath);
res.writeHead(200, {
"Content-Type": mimeType,
"Cache-Control": "no-cache",
});
const stream = fs.createReadStream(fullPath);
stream.pipe(res);
stream.on("error", () => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
});
});
}
// Zertifikate laden und HTTPS-Server starten
const pfxFile = path.join(CERT_DIR, "localhost.pfx");
const pfxPasswordFile = path.join(CERT_DIR, "pfx-password.txt");
if (!fs.existsSync(pfxFile) || !fs.existsSync(pfxPasswordFile)) {
console.error("Zertifikate nicht gefunden in:", CERT_DIR);
console.error("Bitte zuerst setup.ps1 ausfuehren.");
process.exit(1);
}
const pfxPassword = fs.readFileSync(pfxPasswordFile, "utf-8").trim();
const serverOptions = {
pfx: fs.readFileSync(pfxFile),
passphrase: pfxPassword,
};
const server = https.createServer(serverOptions, handleRequest);
server.listen(PORT, "127.0.0.1", () => {
console.log(`Starface Outlook Sync - Lokaler Server`);
console.log(`Läuft auf: https://localhost:${PORT}`);
console.log(`Webroot: ${WEBROOT}`);
console.log(`PID: ${process.pid}`);
});
server.on("error", (err) => {
if (err.code === "EADDRINUSE") {
console.error(`Port ${PORT} ist bereits belegt.`);
console.error("Bitte anderen Port in config.json wählen.");
} else {
console.error("Server-Fehler:", err.message);
}
process.exit(1);
});
// Graceful Shutdown
process.on("SIGINT", () => {
console.log("Server wird beendet...");
server.close(() => process.exit(0));
});
process.on("SIGTERM", () => {
console.log("Server wird beendet...");
server.close(() => process.exit(0));
});
+90
View File
@@ -0,0 +1,90 @@
; Inno Setup Script fuer Starface Outlook Sync
; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
#define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.22"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"
[Setup]
AppId={{B7E3F4A1-2C8D-4E5F-9A0B-1D2E3F4A5B6C}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
DefaultDirName={autopf}\StarfaceOutlookSync
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=..\dist
OutputBaseFilename=StarfaceOutlookSync_Setup_{#MyAppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName}
[Languages]
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
[Files]
; Hauptanwendung - Pfad anpassen nach Build
Source: "..\src\StarfaceOutlookSync\bin\Release\net8.0-windows\win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{#MyAppName} deinstallieren"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Registry]
; Autostart fuer alle Benutzer (HKLM)
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "StarfaceOutlookSync"; ValueData: """{app}\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: autostart
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} jetzt starten"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillApp"
[UninstallDelete]
Type: filesandordirs; Name: "{userappdata}\StarfaceOutlookSync"
[Code]
// Pruefe ob .NET 8 Desktop Runtime installiert ist
function IsDotNet8Installed(): Boolean;
var
ResultCode: Integer;
begin
// dotnet --list-runtimes enthaelt "Microsoft.WindowsDesktop.App 8.x"
Result := Exec('dotnet', '--list-runtimes', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if Result then
Result := ResultCode = 0;
// Einfacher Check: dotnet.exe muss existieren
Result := FileExists(ExpandConstant('{commonpf}\dotnet\dotnet.exe')) or
FileExists(ExpandConstant('{commonpf64}\dotnet\dotnet.exe'));
end;
function InitializeSetup(): Boolean;
var
ErrorCode: Integer;
begin
Result := True;
if not IsDotNet8Installed() then
begin
if MsgBox('.NET 8 Desktop Runtime wird benoetigt.' + #13#10 + #13#10 +
'Soll die Download-Seite geoeffnet werden?',
mbConfirmation, MB_YESNO) = IDYES then
begin
ShellExec('open', 'https://dotnet.microsoft.com/download/dotnet/8.0/runtime', '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode);
end;
Result := False;
end;
end;
-543
View File
@@ -1,543 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Setup-Script fuer Starface Outlook Sync Add-in.
.DESCRIPTION
- Fragt Starface-Host ab (zum Import des SSL-Zertifikats)
- Fragt lokalen Port ab fuer den Webserver
- Extrahiert das SSL-Zertifikat der Starface und importiert es als vertrauenswuerdig
- Erstellt eine lokale CA und ein localhost-Zertifikat
- Installiert den lokalen HTTPS-Webserver als Windows Scheduled Task
- Registriert das Outlook Add-in
Login-Daten werden NICHT benoetigt - diese werden spaeter im Add-in selbst
in den Sync-Profilen konfiguriert.
#>
param(
[string]$InstallDir = "$env:ProgramFiles\StarfaceOutlookSync"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# ============================================================
# Hilfsfunktionen
# ============================================================
function Write-Header($text) {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " $text" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host ""
}
function Write-Step($text) {
Write-Host " [OK] $text" -ForegroundColor Green
}
function Write-Warn($text) {
Write-Host " [!] $text" -ForegroundColor Yellow
}
function Write-Err($text) {
Write-Host " [X] $text" -ForegroundColor Red
}
function Test-NodeJs {
try {
$version = & node --version 2>$null
if ($version) {
Write-Step "Node.js gefunden: $version"
return $true
}
} catch {}
return $false
}
function Test-PortAvailable($port) {
$listener = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
return ($null -eq $listener)
}
function Import-StarfaceCert($host_, $port_) {
Write-Step "Verbinde mit ${host_}:${port_} ..."
$tcpClient = New-Object System.Net.Sockets.TcpClient
$tcpClient.Connect($host_, $port_)
$sslStream = New-Object System.Net.Security.SslStream(
$tcpClient.GetStream(),
$false,
{ $true } # Alle Zertifikate akzeptieren fuer den Abruf
)
$sslStream.AuthenticateAsClient($host_)
$remoteCert = $sslStream.RemoteCertificate
$x509Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($remoteCert)
$sslStream.Close()
$tcpClient.Close()
Write-Step "Zertifikat erhalten: $($x509Cert.Subject)"
Write-Step "Aussteller: $($x509Cert.Issuer)"
Write-Step "Gueltig bis: $($x509Cert.NotAfter)"
# In den Trusted Root Store importieren
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine
)
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$store.Add($x509Cert)
$store.Close()
Write-Step "Starface-Zertifikat als vertrauenswuerdig importiert."
return $x509Cert
}
# ============================================================
# Voraussetzungen pruefen
# ============================================================
Write-Header "Starface Outlook Sync - Setup"
# Node.js pruefen und ggf. installieren
if (-not (Test-NodeJs)) {
Write-Warn "Node.js ist nicht installiert."
Write-Host " Node.js wird fuer den lokalen Webserver benoetigt." -ForegroundColor Gray
Write-Host ""
$installNode = Read-Host "Node.js jetzt automatisch herunterladen und installieren? (j/n)"
if ($installNode -eq "j") {
Write-Step "Ermittle System-Architektur ..."
$arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }
$nodeVersion = "v22.14.0" # LTS
$msiUrl = "https://nodejs.org/dist/$nodeVersion/node-$nodeVersion-$arch.msi"
$msiPath = Join-Path $env:TEMP "node-$nodeVersion-$arch.msi"
Write-Step "Lade Node.js $nodeVersion ($arch) herunter ..."
Write-Host " $msiUrl" -ForegroundColor Gray
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue' # Beschleunigt den Download
Invoke-WebRequest -Uri $msiUrl -OutFile $msiPath -UseBasicParsing
if (-not (Test-Path $msiPath)) {
Write-Err "Download fehlgeschlagen."
exit 1
}
$fileSize = [math]::Round((Get-Item $msiPath).Length / 1MB, 1)
Write-Step "Download abgeschlossen ($fileSize MB)"
Write-Step "Installiere Node.js (bitte warten) ..."
$msiArgs = "/i `"$msiPath`" /qn /norestart"
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -PassThru
if ($process.ExitCode -ne 0) {
Write-Err "Installation fehlgeschlagen (Exit-Code: $($process.ExitCode))"
Write-Host " Bitte Node.js manuell installieren: https://nodejs.org/" -ForegroundColor Yellow
exit 1
}
# PATH aktualisieren (MSI fuegt Node.js zum System-PATH hinzu)
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
# Aufraeumen
Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
if (Test-NodeJs) {
Write-Step "Node.js erfolgreich installiert!"
} else {
Write-Err "Node.js wurde installiert, ist aber nicht im PATH gefunden."
Write-Host " Bitte das Setup nach einem Neustart erneut ausfuehren." -ForegroundColor Yellow
exit 1
}
} catch {
Write-Err "Fehler beim Download/Installation: $_"
Write-Host " Bitte Node.js manuell installieren: https://nodejs.org/" -ForegroundColor Yellow
exit 1
}
} else {
Write-Host " Bitte Node.js manuell installieren: https://nodejs.org/" -ForegroundColor Yellow
Write-Host " (LTS-Version empfohlen)" -ForegroundColor Yellow
Read-Host "Eingabetaste zum Beenden"
exit 1
}
}
# ============================================================
# Benutzereingaben
# ============================================================
Write-Header "Starface-Verbindung"
Write-Host " Das Add-in kommuniziert per HTTPS mit der Starface." -ForegroundColor Gray
Write-Host " Da die Starface meist ein selbstsigniertes Zertifikat verwendet," -ForegroundColor Gray
Write-Host " muss dieses Zertifikat (CA) einmalig als vertrauenswuerdig" -ForegroundColor Gray
Write-Host " importiert werden. Dafuer wird hier nur die Adresse benoetigt." -ForegroundColor Gray
Write-Host "" -ForegroundColor Gray
Write-Host " Login-Daten werden hier NICHT benoetigt - diese konfigurieren" -ForegroundColor Gray
Write-Host " Sie spaeter im Add-in in den Sync-Profilen." -ForegroundColor Gray
Write-Host ""
$starfaceHost = Read-Host "Starface Host/IP-Adresse (z.B. 192.168.1.100 oder pbx.firma.local)"
if ([string]::IsNullOrWhiteSpace($starfaceHost)) {
Write-Err "Kein Host angegeben."
exit 1
}
$starfacePortInput = Read-Host "Starface HTTPS-Port [443]"
$starfacePort = if ([string]::IsNullOrWhiteSpace($starfacePortInput)) { 443 } else { [int]$starfacePortInput }
Write-Host ""
Write-Header "Lokaler Webserver"
$localPortInput = Read-Host "Lokaler HTTPS-Port fuer das Add-in [444]"
$localPort = if ([string]::IsNullOrWhiteSpace($localPortInput)) { 444 } else { [int]$localPortInput }
if (-not (Test-PortAvailable $localPort)) {
Write-Warn "Port $localPort ist bereits belegt!"
$localPortInput = Read-Host "Bitte einen anderen Port waehlen"
$localPort = [int]$localPortInput
if (-not (Test-PortAvailable $localPort)) {
Write-Err "Port $localPort ist ebenfalls belegt. Abbruch."
exit 1
}
}
Write-Step "Verwende Port: $localPort"
# ============================================================
# Schritt 1: Starface SSL-Zertifikat extrahieren und importieren
# ============================================================
Write-Header "Schritt 1: Starface-Zertifikat importieren"
try {
Import-StarfaceCert $starfaceHost $starfacePort
} catch {
Write-Err "Fehler beim Abrufen des Starface-Zertifikats: $_"
Write-Warn "Die Verbindung zur Starface API koennte fehlschlagen."
Write-Warn "Sie koennen das Zertifikat spaeter nachholen mit:"
Write-Host " .\import-cert.ps1 -StarfaceHost $starfaceHost -Port $starfacePort" -ForegroundColor White
Write-Host ""
Write-Host " Moechten Sie trotzdem fortfahren? (j/n)" -ForegroundColor Yellow
$continue = Read-Host
if ($continue -ne "j") { exit 1 }
}
# ============================================================
# Schritt 2: Lokale CA und localhost-Zertifikat erstellen
# ============================================================
Write-Header "Schritt 2: Lokale Zertifikate erstellen"
$certDir = Join-Path $InstallDir "certs"
New-Item -ItemType Directory -Path $certDir -Force | Out-Null
# Lokale CA erstellen
Write-Step "Erstelle lokale CA ..."
$caParams = @{
Subject = "CN=Starface Outlook Sync Local CA"
KeyLength = 2048
KeyAlgorithm = "RSA"
HashAlgorithm = "SHA256"
KeyExportPolicy = "Exportable"
NotAfter = (Get-Date).AddYears(10)
CertStoreLocation = "Cert:\LocalMachine\My"
KeyUsage = "CertSign", "CRLSign"
TextExtension = @("2.5.29.19={text}CA=true&pathlength=1")
}
$caCert = New-SelfSignedCertificate @caParams
Write-Step "Lokale CA erstellt: $($caCert.Thumbprint)"
# CA in Trusted Root Store importieren
$rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine
)
$rootStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$rootStore.Add($caCert)
$rootStore.Close()
Write-Step "Lokale CA als vertrauenswuerdig importiert."
# Localhost-Zertifikat erstellen, signiert von der lokalen CA
Write-Step "Erstelle localhost-Zertifikat ..."
$localhostParams = @{
Subject = "CN=localhost"
KeyLength = 2048
KeyAlgorithm = "RSA"
HashAlgorithm = "SHA256"
KeyExportPolicy = "Exportable"
NotAfter = (Get-Date).AddYears(5)
CertStoreLocation = "Cert:\LocalMachine\My"
Signer = $caCert
DnsName = "localhost", "127.0.0.1"
TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.1") # Server Authentication
}
$localhostCert = New-SelfSignedCertificate @localhostParams
Write-Step "Localhost-Zertifikat erstellt: $($localhostCert.Thumbprint)"
# Zertifikat als PFX exportieren (fuer Node.js)
# PFX funktioniert auf allen Windows-Versionen (.NET Framework + .NET Core)
Write-Step "Exportiere Zertifikate fuer den Webserver ..."
$pfxPassword = [guid]::NewGuid().ToString()
$pfxSecure = ConvertTo-SecureString -String $pfxPassword -Force -AsPlainText
$pfxPath = Join-Path $certDir "localhost.pfx"
Export-PfxCertificate -Cert $localhostCert -FilePath $pfxPath -Password $pfxSecure | Out-Null
# Passwort in config speichern (wird vom Server gelesen)
Set-Content -Path (Join-Path $certDir "pfx-password.txt") -Value $pfxPassword -Encoding ASCII
Write-Step "PFX-Zertifikat exportiert."
# CA-Zertifikat exportieren (fuer Deinstallation)
$caCertPem = "-----BEGIN CERTIFICATE-----`n"
$caCertPem += [Convert]::ToBase64String($caCert.RawData, [Base64FormattingOptions]::InsertLineBreaks)
$caCertPem += "`n-----END CERTIFICATE-----"
Set-Content -Path (Join-Path $certDir "local-ca.crt") -Value $caCertPem -Encoding ASCII
# Thumbprints speichern (fuer Deinstallation)
$certInfo = @{
caThumbprint = $caCert.Thumbprint
localhostThumbprint = $localhostCert.Thumbprint
}
$certInfo | ConvertTo-Json | Set-Content (Join-Path $InstallDir "cert-info.json") -Encoding UTF8
Write-Step "Zertifikate exportiert nach: $certDir"
# ============================================================
# Schritt 3: Add-in Dateien installieren
# ============================================================
Write-Header "Schritt 3: Add-in Dateien installieren"
$webrootDir = Join-Path $InstallDir "webroot"
New-Item -ItemType Directory -Path $webrootDir -Force | Out-Null
# Pruefen ob dist-Ordner existiert (bereits gebaut)
$distDir = Join-Path $scriptDir "..\dist"
if (-not (Test-Path $distDir)) {
Write-Step "Baue Add-in (npm run build) ..."
Push-Location (Join-Path $scriptDir "..")
& npm run build 2>&1 | Out-Null
Pop-Location
}
if (-not (Test-Path $distDir)) {
Write-Err "Build fehlgeschlagen. dist-Ordner nicht gefunden."
exit 1
}
# Dateien kopieren
Copy-Item -Path "$distDir\*" -Destination $webrootDir -Recurse -Force
Write-Step "Add-in Dateien kopiert nach: $webrootDir"
# manifest.xml mit korrektem Port anpassen
$manifestPath = Join-Path $webrootDir "manifest.xml"
if (Test-Path $manifestPath) {
$manifestContent = Get-Content $manifestPath -Raw -Encoding UTF8
$manifestContent = $manifestContent -replace "https://localhost:3000", "https://localhost:$localPort"
Set-Content -Path $manifestPath -Value $manifestContent -Encoding UTF8
Write-Step "manifest.xml angepasst (Port: $localPort)"
}
# Auch eine Kopie der manifest.xml ins Installationsverzeichnis
Copy-Item -Path $manifestPath -Destination (Join-Path $InstallDir "manifest.xml") -Force
# ============================================================
# Schritt 4: Server-Konfiguration und Installation
# ============================================================
Write-Header "Schritt 4: Lokalen Webserver einrichten"
# Server-Dateien kopieren
Copy-Item -Path (Join-Path $scriptDir "local-server.js") -Destination $InstallDir -Force
# import-cert.ps1 ins Installationsverzeichnis kopieren (fuer spaetere Nutzung)
$importCertSrc = Join-Path $scriptDir "import-cert.ps1"
if (Test-Path $importCertSrc) {
Copy-Item -Path $importCertSrc -Destination $InstallDir -Force
}
# uninstall.ps1 ins Installationsverzeichnis kopieren
$uninstallSrc = Join-Path $scriptDir "uninstall.ps1"
if (Test-Path $uninstallSrc) {
Copy-Item -Path $uninstallSrc -Destination $InstallDir -Force
}
# config.json erstellen
$serverConfig = @{
port = $localPort
}
$serverConfig | ConvertTo-Json | Set-Content (Join-Path $InstallDir "config.json") -Encoding UTF8
Write-Step "Server-Konfiguration erstellt."
# Windows Scheduled Task erstellen (startet beim Systemstart)
$taskName = "StarfaceOutlookSyncServer"
# Bestehenden Task entfernen falls vorhanden
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$nodeExe = (Get-Command node).Source
$serverScript = Join-Path $InstallDir "local-server.js"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$serverScript`"" -WorkingDirectory $InstallDir
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Lokaler HTTPS-Server fuer Starface Outlook Sync Add-in" | Out-Null
# Task sofort starten
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 2
# Pruefen ob der Server laeuft
try {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
$response = Invoke-WebRequest -Uri "https://localhost:$localPort/taskpane.html" -UseBasicParsing -TimeoutSec 5
if ($response.StatusCode -eq 200) {
Write-Step "Lokaler Webserver laeuft auf https://localhost:$localPort"
}
} catch {
Write-Warn "Server-Test fehlgeschlagen. Server startet moeglicherweise verzoegert."
}
# ============================================================
# Schritt 5: Outlook Add-in registrieren
# ============================================================
Write-Header "Schritt 5: Outlook Add-in registrieren"
# Manifest-Katalog-Ordner einrichten
$catalogDir = Join-Path $InstallDir "manifest-catalog"
New-Item -ItemType Directory -Path $catalogDir -Force | Out-Null
Copy-Item -Path (Join-Path $InstallDir "manifest.xml") -Destination $catalogDir -Force
# Terminal Server erkennen
$isTerminalServer = $false
$rdRole = Get-WindowsFeature -Name "RDS-RD-Server" -ErrorAction SilentlyContinue
if ($rdRole -and $rdRole.Installed) {
$isTerminalServer = $true
} elseif ((Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" -Name "TSAppCompat" -ErrorAction SilentlyContinue).TSAppCompat -eq 1) {
$isTerminalServer = $true
}
if ($isTerminalServer) {
Write-Step "Terminal Server erkannt - registriere Add-in fuer alle Benutzer (HKLM)."
}
# Outlook Classic: Shared Folder Catalog per Registry konfigurieren
# Terminal Server: HKLM (gilt fuer alle User), sonst HKCU
$outlookVersions = @("16.0", "15.0")
$registeredClassic = $false
foreach ($ver in $outlookVersions) {
# Pruefen ob diese Outlook-Version installiert ist
$outlookPath = "HKLM:\Software\Microsoft\Office\$ver\Outlook"
if (-not (Test-Path $outlookPath)) {
$outlookPath = "HKLM:\Software\WOW6432Node\Microsoft\Office\$ver\Outlook"
}
if (Test-Path $outlookPath) {
Write-Step "Outlook $ver (Classic) gefunden."
$catalogId = [guid]::NewGuid().ToString("B")
# Bei Terminal Server: in HKLM registrieren (gilt fuer alle User)
if ($isTerminalServer) {
$outlookRegPath = "HKLM:\Software\Microsoft\Office\$ver\WEF\TrustedCatalogs"
$catalogRegPath = Join-Path $outlookRegPath $catalogId
New-Item -Path $catalogRegPath -Force | Out-Null
New-ItemProperty -Path $catalogRegPath -Name "Url" -Value $catalogDir -PropertyType String -Force | Out-Null
New-ItemProperty -Path $catalogRegPath -Name "Flags" -Value 1 -PropertyType DWord -Force | Out-Null
Write-Step "Add-in Katalog fuer alle Benutzer registriert (HKLM)."
}
# Immer auch in HKCU fuer den aktuellen User (Fallback / Einzelplatz)
$outlookRegPathUser = "HKCU:\Software\Microsoft\Office\$ver\WEF\TrustedCatalogs"
$catalogRegPathUser = Join-Path $outlookRegPathUser $catalogId
New-Item -Path $catalogRegPathUser -Force | Out-Null
New-ItemProperty -Path $catalogRegPathUser -Name "Url" -Value $catalogDir -PropertyType String -Force | Out-Null
New-ItemProperty -Path $catalogRegPathUser -Name "Flags" -Value 1 -PropertyType DWord -Force | Out-Null
Write-Step "Add-in Katalog fuer Outlook Classic registriert."
$registeredClassic = $true
}
}
if (-not $registeredClassic) {
Write-Warn "Outlook Classic nicht in der Registry gefunden."
}
# Pruefen ob New Outlook installiert ist
$newOutlook = Get-AppxPackage -Name "Microsoft.OutlookForWindows" -ErrorAction SilentlyContinue
if ($newOutlook) {
Write-Step "Neues Outlook gefunden: Version $($newOutlook.Version)"
Write-Warn "Fuer das neue Outlook muss das Add-in manuell hinzugefuegt werden:"
Write-Host " 1. Neues Outlook oeffnen" -ForegroundColor White
Write-Host " 2. Einstellungen (Zahnrad) -> Add-Ins verwalten" -ForegroundColor White
Write-Host " 3. 'Benutzerdefinierte Add-Ins' -> 'Aus Datei hinzufuegen'" -ForegroundColor White
Write-Host " 4. Datei waehlen: $catalogDir\manifest.xml" -ForegroundColor White
} else {
Write-Step "Neues Outlook nicht installiert (nur Classic erkannt)."
}
# ============================================================
# Schritt 6: Installationsinfo speichern
# ============================================================
$installInfo = @{
installDir = $InstallDir
localPort = $localPort
taskName = $taskName
installedAt = (Get-Date).ToString("o")
catalogDir = $catalogDir
manifestUrl = "https://localhost:$localPort"
caThumbprint = $caCert.Thumbprint
localhostThumbprint = $localhostCert.Thumbprint
}
$installInfo | ConvertTo-Json | Set-Content (Join-Path $InstallDir "install-info.json") -Encoding UTF8
# ============================================================
# Zusammenfassung
# ============================================================
Write-Header "Installation abgeschlossen!"
Write-Host " Installationsverzeichnis: $InstallDir" -ForegroundColor White
Write-Host " Lokaler Server: https://localhost:$localPort" -ForegroundColor White
Write-Host " Manifest: $catalogDir\manifest.xml" -ForegroundColor White
Write-Host ""
if ($registeredClassic) {
Write-Host " Outlook Classic: Add-in wurde automatisch registriert." -ForegroundColor Green
Write-Host " Outlook neu starten, dann 'Kontakt-Sync' im Ribbon suchen." -ForegroundColor Green
}
if ($newOutlook) {
Write-Host ""
Write-Host " Neues Outlook: Bitte Add-in manuell hinzufuegen (siehe oben)." -ForegroundColor Yellow
}
Write-Host ""
Write-Host " Naechster Schritt: Im Add-in ein Sync-Profil mit den" -ForegroundColor White
Write-Host " Starface-Zugangsdaten einrichten." -ForegroundColor White
Write-Host ""
Write-Host " Weitere Starface-Anlagen einbinden? Zertifikat importieren mit:" -ForegroundColor Gray
Write-Host " .\import-cert.ps1 -StarfaceHost <host> [-Port <port>]" -ForegroundColor Gray
Write-Host ""
Write-Host " Deinstallation: uninstall.ps1 als Administrator ausfuehren" -ForegroundColor Gray
Write-Host ""
# Firewall-Regel fuer den lokalen Port (nur localhost, rein vorsichtshalber)
New-NetFirewallRule -DisplayName "Starface Outlook Sync" -Direction Inbound -LocalPort $localPort -Protocol TCP -Action Allow -Profile Private -ErrorAction SilentlyContinue | Out-Null
Read-Host "Eingabetaste zum Beenden"
-171
View File
@@ -1,171 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Deinstalliert das Starface Outlook Sync Add-in.
.DESCRIPTION
Entfernt Server, Zertifikate, Scheduled Task, Registry-Eintraege und Dateien.
#>
param(
[string]$InstallDir = "$env:ProgramFiles\StarfaceOutlookSync"
)
$ErrorActionPreference = "Stop"
function Write-Header($text) {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " $text" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host ""
}
function Write-Step($text) {
Write-Host " [OK] $text" -ForegroundColor Green
}
function Write-Warn($text) {
Write-Host " [!] $text" -ForegroundColor Yellow
}
Write-Header "Starface Outlook Sync - Deinstallation"
# Sicherheitsabfrage
Write-Host " Dies entfernt das Starface Outlook Sync Add-in vollstaendig." -ForegroundColor Yellow
Write-Host " Installationsverzeichnis: $InstallDir" -ForegroundColor Yellow
$confirm = Read-Host " Fortfahren? (j/n)"
if ($confirm -ne "j") {
Write-Host " Abgebrochen." -ForegroundColor Gray
exit 0
}
# ============================================================
# Schritt 1: Scheduled Task stoppen und entfernen
# ============================================================
Write-Header "Schritt 1: Server stoppen"
$taskName = "StarfaceOutlookSyncServer"
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($task) {
if ($task.State -eq "Running") {
Stop-ScheduledTask -TaskName $taskName
Write-Step "Server gestoppt."
}
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
Write-Step "Scheduled Task entfernt."
} else {
Write-Warn "Scheduled Task '$taskName' nicht gefunden."
}
# ============================================================
# Schritt 2: Zertifikate entfernen
# ============================================================
Write-Header "Schritt 2: Zertifikate entfernen"
$certInfoPath = Join-Path $InstallDir "cert-info.json"
if (Test-Path $certInfoPath) {
$certInfo = Get-Content $certInfoPath -Raw | ConvertFrom-Json
# Localhost-Zertifikat aus dem persoenlichen Store entfernen
if ($certInfo.localhostThumbprint) {
$cert = Get-ChildItem "Cert:\LocalMachine\My" | Where-Object { $_.Thumbprint -eq $certInfo.localhostThumbprint }
if ($cert) {
Remove-Item $cert.PSPath -Force
Write-Step "Localhost-Zertifikat entfernt."
}
}
# Lokale CA aus dem Root Store entfernen
if ($certInfo.caThumbprint) {
$caCert = Get-ChildItem "Cert:\LocalMachine\Root" | Where-Object { $_.Thumbprint -eq $certInfo.caThumbprint }
if ($caCert) {
Remove-Item $caCert.PSPath -Force
Write-Step "Lokale CA entfernt."
}
# Auch aus dem persoenlichen Store
$caCertMy = Get-ChildItem "Cert:\LocalMachine\My" | Where-Object { $_.Thumbprint -eq $certInfo.caThumbprint }
if ($caCertMy) {
Remove-Item $caCertMy.PSPath -Force
}
}
} else {
Write-Warn "cert-info.json nicht gefunden. Zertifikate muessen ggf. manuell entfernt werden."
}
# ============================================================
# Schritt 3: Outlook Add-in Registrierung entfernen
# ============================================================
Write-Header "Schritt 3: Outlook-Registrierung entfernen"
$outlookVersions = @("16.0", "15.0")
foreach ($ver in $outlookVersions) {
# HKCU (Einzelplatz / aktueller User)
$catalogBasePath = "HKCU:\Software\Microsoft\Office\$ver\WEF\TrustedCatalogs"
if (Test-Path $catalogBasePath) {
$catalogs = Get-ChildItem $catalogBasePath -ErrorAction SilentlyContinue
foreach ($catalog in $catalogs) {
$url = Get-ItemProperty -Path $catalog.PSPath -Name "Url" -ErrorAction SilentlyContinue
if ($url -and $url.Url -like "*StarfaceOutlookSync*") {
Remove-Item $catalog.PSPath -Recurse -Force
Write-Step "Outlook $ver Katalog-Registrierung entfernt (HKCU)."
}
}
}
# HKLM (Terminal Server / alle User)
$catalogBasePathLM = "HKLM:\Software\Microsoft\Office\$ver\WEF\TrustedCatalogs"
if (Test-Path $catalogBasePathLM) {
$catalogs = Get-ChildItem $catalogBasePathLM -ErrorAction SilentlyContinue
foreach ($catalog in $catalogs) {
$url = Get-ItemProperty -Path $catalog.PSPath -Name "Url" -ErrorAction SilentlyContinue
if ($url -and $url.Url -like "*StarfaceOutlookSync*") {
Remove-Item $catalog.PSPath -Recurse -Force
Write-Step "Outlook $ver Katalog-Registrierung entfernt (HKLM)."
}
}
}
}
# ============================================================
# Schritt 4: Firewall-Regel entfernen
# ============================================================
Write-Header "Schritt 4: Firewall-Regel entfernen"
Remove-NetFirewallRule -DisplayName "Starface Outlook Sync" -ErrorAction SilentlyContinue
Write-Step "Firewall-Regel entfernt."
# ============================================================
# Schritt 5: Dateien entfernen
# ============================================================
Write-Header "Schritt 5: Dateien entfernen"
if (Test-Path $InstallDir) {
Remove-Item -Path $InstallDir -Recurse -Force
Write-Step "Installationsverzeichnis entfernt: $InstallDir"
} else {
Write-Warn "Verzeichnis nicht gefunden: $InstallDir"
}
# ============================================================
# Zusammenfassung
# ============================================================
Write-Header "Deinstallation abgeschlossen"
Write-Host " Das Starface Outlook Sync Add-in wurde entfernt." -ForegroundColor Green
Write-Host ""
Write-Host " Hinweis: Falls das Add-in im neuen Outlook manuell" -ForegroundColor Gray
Write-Host " hinzugefuegt wurde, muss es dort auch manuell entfernt werden:" -ForegroundColor Gray
Write-Host " Einstellungen -> Add-Ins verwalten -> Add-In entfernen" -ForegroundColor Gray
Write-Host ""
Write-Host " Das Starface-Zertifikat wurde NICHT entfernt," -ForegroundColor Yellow
Write-Host " da es ggf. von anderen Anwendungen benoetigt wird." -ForegroundColor Yellow
Write-Host ""
Read-Host "Eingabetaste zum Beenden"
-209
View File
@@ -1,209 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
<Version>1.0.0.0</Version>
<ProviderName>Starface Outlook Sync</ProviderName>
<DefaultLocale>de-DE</DefaultLocale>
<DisplayName DefaultValue="Starface Kontakt-Sync" />
<Description DefaultValue="Synchronisiert Outlook-Kontakte mit Starface Telefonanlage" />
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png" />
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png" />
<SupportUrl DefaultValue="https://localhost:3000" />
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox" />
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1" />
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html" />
<RequestedHeight>450</RequestedHeight>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteMailbox</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" />
<Rule xsi:type="ItemIs" ItemType="Appointment" />
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="taskpaneUrl" />
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroup">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtn">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroupCompose">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtnCompose">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://localhost:3000/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://localhost:3000/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://localhost:3000/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://localhost:3000/taskpane.html" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="groupLabel" DefaultValue="Starface Sync" />
<bt:String id="taskpaneBtnLabel" DefaultValue="Kontakt-Sync" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="taskpaneBtnDesc" DefaultValue="Kontakte zwischen Outlook und Starface synchronisieren" />
</bt:LongStrings>
</Resources>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.5">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="taskpaneUrl" />
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroup2">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtn2">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="starfaceSyncGroupCompose2">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="openTaskpaneBtnCompose2">
<Label resid="taskpaneBtnLabel" />
<Supertip>
<Title resid="taskpaneBtnLabel" />
<Description resid="taskpaneBtnDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://localhost:3000/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://localhost:3000/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://localhost:3000/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://localhost:3000/taskpane.html" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="groupLabel" DefaultValue="Starface Sync" />
<bt:String id="taskpaneBtnLabel" DefaultValue="Kontakt-Sync" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="taskpaneBtnDesc" DefaultValue="Kontakte zwischen Outlook und Starface synchronisieren" />
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
-6252
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "starface-outlook-sync",
"version": "1.0.0",
"description": "Outlook Add-in to sync contacts with Starface PBX",
"private": true,
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev": "webpack serve --mode development",
"start": "webpack serve --mode development --https",
"lint": "eslint src/**/*.{ts,tsx}",
"validate": "office-addin-manifest validate manifest.xml"
},
"dependencies": {
"@fluentui/react": "^8.120.0",
"@fluentui/react-icons": "^2.0.245",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/office-js": "^1.0.410",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3",
"office-addin-dev-certs": "^2.0.6",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
}
}
Executable
+244
View File
@@ -0,0 +1,244 @@
#!/bin/bash
#
# Release-Script fuer Starface Outlook Sync
#
# Aktualisiert Versionsnummer, baut die App, erstellt den Installer
# und laedt das Release auf Gitea hoch.
#
# Voraussetzungen:
# - .NET 8 SDK (dotnet)
# - Docker (fuer Inno Setup)
# - curl (fuer Gitea API)
# - Gitea-Zugangsdaten (werden beim Start abgefragt)
#
# Verwendung:
# ./release.sh 0.1.0.0
# ./release.sh 0.1.0.0 "Erster Beta-Release"
set -e
# ============================================================
# Konfiguration
# ============================================================
GITEA_URL="https://git.hacker-net.de"
REPO_OWNER="Hacker-Software"
REPO_NAME="starface-outlook-sync-addin"
DOTNET_PATH="${DOTNET_PATH:-$HOME/.dotnet}"
PROJECT="src/StarfaceOutlookSync/StarfaceOutlookSync.csproj"
ABOUT_FORM="src/StarfaceOutlookSync/UI/AboutForm.cs"
INNO_SCRIPT="installer/setup.iss"
DIST_DIR="dist"
# ============================================================
# Argumente pruefen
# ============================================================
VERSION="${1}"
RELEASE_NOTE="${2:-Release v${VERSION}}"
if [ -z "$VERSION" ]; then
echo "Verwendung: $0 <version> [release-beschreibung]"
echo "Beispiel: $0 0.1.0.0 \"Erster Beta-Release\""
echo ""
echo "Aktuelle Version in .csproj:"
grep '<Version>' "$PROJECT" | head -1 | sed 's/.*<Version>/ /;s/<.*//'
exit 1
fi
# Versionsformat pruefen (x.x.x.x)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Fehler: Version muss im Format x.x.x.x sein (z.B. 0.1.0.0)"
exit 1
fi
# ============================================================
# Gitea-Zugangsdaten abfragen
# ============================================================
read -p "Gitea Benutzername: " GITEA_USER
read -s -p "Gitea Kennwort: " GITEA_PASS
echo ""
if [ -z "$GITEA_USER" ] || [ -z "$GITEA_PASS" ]; then
echo "Fehler: Benutzername und Kennwort erforderlich."
exit 1
fi
GITEA_AUTH="${GITEA_USER}:${GITEA_PASS}"
# ============================================================
# Tools pruefen
# ============================================================
export PATH="$DOTNET_PATH:$PATH"
echo "=== Starface Outlook Sync - Release v${VERSION} ==="
echo ""
echo "[1/7] Pruefe Voraussetzungen..."
if ! command -v dotnet &> /dev/null; then
echo " Fehler: dotnet nicht gefunden."
echo " Install: wget https://dot.net/v1/dotnet-install.sh -O- | bash /dev/stdin --channel 8.0"
exit 1
fi
echo " dotnet $(dotnet --version)"
if ! command -v docker &> /dev/null; then
echo " Fehler: docker nicht gefunden."
echo " Install: https://docs.docker.com/engine/install/debian/"
exit 1
fi
echo " docker $(docker --version | cut -d' ' -f3 | tr -d ',')"
if ! command -v curl &> /dev/null; then
echo " Fehler: curl nicht gefunden."
exit 1
fi
# Pruefen ob Git sauber ist
if [ -n "$(git status --porcelain)" ]; then
echo ""
echo " Warnung: Uncommitted changes vorhanden!"
git status --short
echo ""
read -p " Trotzdem fortfahren? (j/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[jJyY]$ ]]; then
exit 1
fi
fi
# ============================================================
# Versionsnummern aktualisieren
# ============================================================
echo ""
echo "[2/7] Aktualisiere Versionsnummer auf ${VERSION}..."
# .csproj
sed -i "s|<Version>.*</Version>|<Version>${VERSION}</Version>|g" "$PROJECT"
sed -i "s|<AssemblyVersion>.*</AssemblyVersion>|<AssemblyVersion>${VERSION}</AssemblyVersion>|g" "$PROJECT"
sed -i "s|<FileVersion>.*</FileVersion>|<FileVersion>${VERSION}</FileVersion>|g" "$PROJECT"
# AboutForm.cs - nur den Text im String-Literal ersetzen, nicht Variablennamen
sed -i "s|\"Version [0-9.]*\"|\"Version ${VERSION}\"|g" "$ABOUT_FORM"
# Inno Setup
sed -i "s|#define MyAppVersion \".*\"|#define MyAppVersion \"${VERSION}\"|g" "$INNO_SCRIPT"
echo " .csproj, AboutForm.cs, setup.iss aktualisiert."
# ============================================================
# Bauen
# ============================================================
echo ""
echo "[3/7] Baue Release..."
dotnet build "$PROJECT" -c Release --nologo -v quiet
echo " Build erfolgreich."
# ============================================================
# Installer erstellen
# ============================================================
echo ""
echo "[4/7] Erstelle Installer mit Docker..."
mkdir -p "$DIST_DIR"
sudo docker run --rm -v "$PWD:/work" amake/innosetup "$INNO_SCRIPT"
SETUP_FILE="${DIST_DIR}/StarfaceOutlookSync_Setup_${VERSION}.exe"
if [ ! -f "$SETUP_FILE" ]; then
echo " Fehler: Setup-Datei nicht gefunden: $SETUP_FILE"
exit 1
fi
SETUP_SIZE=$(du -h "$SETUP_FILE" | cut -f1)
echo " Installer erstellt: $SETUP_FILE ($SETUP_SIZE)"
# ============================================================
# Git Commit + Tag
# ============================================================
echo ""
echo "[5/7] Git Commit und Tag..."
git add "$PROJECT" "$ABOUT_FORM" "$INNO_SCRIPT"
git commit -m "Release v${VERSION}" --allow-empty
git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " Commit und Tag v${VERSION} erstellt."
# ============================================================
# Git Push
# ============================================================
echo ""
echo "[6/7] Push zu Gitea..."
git push origin main
git push origin "v${VERSION}"
echo " Push erfolgreich."
# ============================================================
# Gitea Release erstellen
# ============================================================
echo ""
echo "[7/7] Erstelle Gitea Release..."
API_URL="${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
# Release erstellen
RELEASE_RESPONSE=$(curl -s -X POST \
-u "${GITEA_AUTH}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"v${VERSION}\",
\"name\": \"v${VERSION}\",
\"body\": \"${RELEASE_NOTE}\",
\"draft\": false,
\"prerelease\": false
}" \
"${API_URL}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo " Fehler beim Erstellen des Release:"
echo " $RELEASE_RESPONSE"
exit 1
fi
echo " Release erstellt (ID: $RELEASE_ID)"
# Setup-EXE als Attachment hochladen
echo " Lade Installer hoch..."
UPLOAD_RESPONSE=$(curl -s -X POST \
-u "${GITEA_AUTH}" \
-F "attachment=@${SETUP_FILE}" \
"${API_URL}/releases/${RELEASE_ID}/assets?name=$(basename $SETUP_FILE)")
ASSET_URL=$(echo "$UPLOAD_RESPONSE" | grep -o '"browser_download_url":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ASSET_URL" ]; then
echo " Warnung: Upload-Antwort:"
echo " $UPLOAD_RESPONSE"
else
echo " Upload erfolgreich: $ASSET_URL"
fi
# ============================================================
# Zusammenfassung
# ============================================================
echo ""
echo "=== Release v${VERSION} abgeschlossen ==="
echo ""
echo " Release: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/v${VERSION}"
echo " Installer: $SETUP_FILE ($SETUP_SIZE)"
echo " Tag: v${VERSION}"
echo ""
@@ -0,0 +1,60 @@
namespace StarfaceOutlookSync.Models
{
public enum SyncDirection
{
Both,
OutlookToStarface,
StarfaceToOutlook
}
public class StarfaceConnection
{
public string Host { get; set; } = "";
public int Port { get; set; } = 443;
public bool UseSsl { get; set; } = true;
public string LoginId { get; set; } = "";
public string Password { get; set; } = "";
}
public class StarfaceAddressBook
{
public string Type { get; set; } = "central"; // central, user, tag
public string UserId { get; set; } = "";
public string TagId { get; set; } = "";
public string Name { get; set; } = "";
public override string ToString() => Name;
}
public class SyncProfile
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StarfaceConnection StarfaceConnection { get; set; } = new StarfaceConnection();
public StarfaceAddressBook StarfaceAddressBook { get; set; } = new StarfaceAddressBook();
public string OutlookFolderPath { get; set; } = "";
public string OutlookFolderName { get; set; } = "Kontakte";
public SyncDirection SyncDirection { get; set; } = SyncDirection.Both;
public string LastSync { get; set; } = "";
public bool Enabled { get; set; } = true;
public int AutoSyncIntervalMinutes { get; set; } = 0; // 0 = manuell
}
public class SyncMapping
{
public string ProfileId { get; set; } = "";
public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string LastSyncHash { get; set; } = "";
}
public class SyncResult
{
public string ProfileName { get; set; } = "";
public string Timestamp { get; set; } = "";
public int Created { get; set; }
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>();
}
}
@@ -0,0 +1,45 @@
namespace StarfaceOutlookSync.Models
{
public class UnifiedContact
{
public string OutlookEntryId { get; set; } = "";
public string StarfaceId { get; set; } = "";
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Company { get; set; } = "";
public string JobTitle { get; set; } = "";
public string Email { get; set; } = "";
public string EmailSecondary { get; set; } = "";
public string PhoneWork { get; set; } = "";
public string PhoneMobile { get; set; } = "";
public string PhoneHome { get; set; } = "";
public string Fax { get; set; } = "";
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public string State { get; set; } = "";
public string Country { get; set; } = "";
public string Website { get; set; } = "";
public string Notes { get; set; } = "";
public string Salutation { get; set; } = "";
public string Title { get; set; } = "";
public string Birthday { get; set; } = "";
public string DisplayName => string.IsNullOrEmpty(LastName)
? (string.IsNullOrEmpty(Company) ? Email : Company)
: $"{FirstName} {LastName}".Trim();
public string GetHash()
{
var fields = new[]
{
FirstName, LastName, Company, JobTitle,
Email, EmailSecondary,
PhoneWork, PhoneMobile, PhoneHome, Fax,
Street, City, PostalCode, State, Country,
Website, Notes, Salutation, Title, Birthday
};
return string.Join("|", fields);
}
}
}
@@ -0,0 +1,96 @@
using System;
using System.IO;
using Microsoft.Win32;
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
{
public class UserSettings
{
public bool StartMinimized { get; set; } = false;
public bool SyncOnStart { get; set; } = false;
public bool AutoAcceptOutlookPrompt { get; set; } = false;
private static readonly string SettingsFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"StarfaceOutlookSync", "settings.json");
public static UserSettings Load()
{
try
{
if (File.Exists(SettingsFile))
return JsonConvert.DeserializeObject<UserSettings>(File.ReadAllText(SettingsFile))
?? new UserSettings();
}
catch { }
return new UserSettings();
}
public void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsFile));
File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(this, Formatting.Indented));
}
catch { }
ApplyOutlookSecuritySetting();
}
public void ApplyOutlookSecuritySetting()
{
// Alle Office-Versionen abdecken (16.0 = 2016/2019/2021/2024/365, 15.0 = 2013)
// Beide Pfade versuchen: Policies (GPO-Pfad) und direkt (normaler User-Pfad)
var versions = new[] { "16.0", "15.0" };
var prefixes = new[]
{
@"Software\Policies\Microsoft\Office", // GPO-Pfad (braucht ggf. Rechte)
@"Software\Microsoft\Office" // Normaler User-Pfad (immer schreibbar)
};
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),
};
foreach (var ver in versions)
{
foreach (var prefix in prefixes)
{
var regPath = $@"{prefix}\{ver}\Outlook\Security";
try
{
if (AutoAcceptOutlookPrompt)
{
var key = Registry.CurrentUser.CreateSubKey(regPath);
if (key != null)
{
foreach (var (name, value) in securityValues)
key.SetValue(name, value, RegistryValueKind.DWord);
key.Close();
}
}
else
{
try { Registry.CurrentUser.DeleteSubKey(regPath, false); } catch { }
}
}
catch { }
}
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Threading;
using System.Windows.Forms;
namespace StarfaceOutlookSync
{
static class Program
{
private static Mutex _mutex;
[STAThread]
static void Main()
{
// Nur eine Instanz erlauben
const string mutexName = "StarfaceOutlookSync_SingleInstance";
_mutex = new Mutex(true, mutexName, out bool createdNew);
if (!createdNew)
{
MessageBox.Show(
"Starface Outlook Sync laeuft bereits.",
"Starface Outlook Sync",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new UI.MainForm());
}
}
}
@@ -0,0 +1,490 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using StarfaceOutlookSync.Models;
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 dynamic _outlookApp;
private bool _weStartedOutlook;
// 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 object GetActiveComObject(string progId)
{
var type = Type.GetTypeFromProgID(progId, false);
if (type == null) return null;
GetActiveObject(type.GUID, IntPtr.Zero, out var obj);
return obj;
}
private dynamic GetOutlookApp()
{
if (_outlookApp != null) return _outlookApp;
// Versuch 1: Laufende Outlook-Instanz finden
try
{
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", false);
if (outlookType != null)
{
_outlookApp = Activator.CreateInstance(outlookType);
_weStartedOutlook = true;
return _outlookApp;
}
}
catch { }
throw new InvalidOperationException(
"Outlook Classic konnte nicht gefunden werden.\n\n" +
"Moegliche Ursachen:\n" +
"- Outlook Classic ist nicht installiert\n" +
"- Outlook Classic ist nicht gestartet\n" +
"- Das neue Outlook wird verwendet (wird noch nicht unterstuetzt)\n\n" +
"Bitte Outlook Classic starten und erneut versuchen.");
}
public List<string> GetContactFolderPaths()
{
var folders = new List<string>();
try
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// 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 {i}: {ex.Message}");
}
}
// Falls nichts gefunden, Standard-Kontaktordner als Fallback
if (folders.Count == 0)
{
try
{
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(dynamic folder, List<string> paths)
{
try
{
if ((int)folder.DefaultItemType == OlContactItem)
{
string path = folder.FolderPath;
if (!paths.Contains(path))
paths.Add(path);
}
var subFolders = folder.Folders;
for (int i = 1; i <= (int)subFolders.Count; i++)
{
try
{
var sub = subFolders[i];
FindContactFoldersRecursive(sub, paths);
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subFolders);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning folder: {ex.Message}");
}
}
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");
if (string.IsNullOrEmpty(folderPath))
return ns.GetDefaultFolder(OlFolderContacts);
try
{
// Versuch 1: Alle Kontaktordner durchsuchen und per FolderPath matchen
var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++)
{
try
{
var store = stores[i];
var root = store.GetRootFolder();
var match = FindFolderByPath(root, folderPath);
if (match != null)
{
Marshal.ReleaseComObject(stores);
return match;
}
Marshal.ReleaseComObject(root);
Marshal.ReleaseComObject(store);
}
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);
}
System.Diagnostics.Debug.WriteLine($"Folder not found by path: {folderPath}, using default");
return ns.GetDefaultFolder(OlFolderContacts);
}
catch
{
return ns.GetDefaultFolder(OlFolderContacts);
}
}
public List<UnifiedContact> GetContacts(string folderPath)
{
var contacts = new List<UnifiedContact>();
try
{
var folder = GetFolderByPath(folderPath);
System.Diagnostics.Debug.WriteLine($"Reading contacts from: {(string)folder.FolderPath}");
var items = folder.Items;
int count = (int)items.Count;
System.Diagnostics.Debug.WriteLine($"Items count: {count}");
for (int i = 1; i <= count; i++)
{
dynamic item = null;
try
{
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 { }
}
}
Marshal.ReleaseComObject(items);
Marshal.ReleaseComObject(folder);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error reading contacts: {ex.Message}");
}
return contacts;
}
public UnifiedContact CreateContact(UnifiedContact contact, string folderPath)
{
try
{
var folder = GetFolderByPath(folderPath);
var items = folder.Items;
dynamic ci = items.Add(OlContactItem);
MapToOutlook(contact, ci);
ci.Save();
contact.OutlookEntryId = ci.EntryID;
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(items);
Marshal.ReleaseComObject(folder);
return contact;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error creating contact: {ex.Message}");
return null;
}
}
public bool UpdateContact(string entryId, UnifiedContact contact)
{
try
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
dynamic ci = ns.GetItemFromID(entryId);
MapToOutlook(contact, ci);
ci.Save();
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(ns);
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error updating contact: {ex.Message}");
return false;
}
}
public bool DeleteContact(string entryId)
{
try
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
dynamic ci = ns.GetItemFromID(entryId);
ci.Delete();
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(ns);
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error deleting contact: {ex.Message}");
return false;
}
}
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 = 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 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;
ci.CompanyName = contact.Company;
ci.JobTitle = contact.JobTitle;
ci.Email1Address = contact.Email;
if (!string.IsNullOrEmpty(contact.EmailSecondary))
ci.Email2Address = contact.EmailSecondary;
ci.BusinessTelephoneNumber = contact.PhoneWork;
ci.MobileTelephoneNumber = contact.PhoneMobile;
ci.HomeTelephoneNumber = contact.PhoneHome;
ci.BusinessFaxNumber = contact.Fax;
ci.BusinessAddressStreet = contact.Street;
ci.BusinessAddressCity = contact.City;
ci.BusinessAddressPostalCode = contact.PostalCode;
ci.BusinessAddressState = contact.State;
ci.BusinessAddressCountry = contact.Country;
ci.WebPage = contact.Website;
ci.Body = contact.Notes;
ci.Title = contact.Salutation;
if (!string.IsNullOrEmpty(contact.Birthday) &&
DateTime.TryParse(contact.Birthday, out var bday) && bday.Year > 1900)
{
ci.Birthday = bday;
}
}
public void Dispose()
{
if (_outlookApp != null)
{
if (_weStartedOutlook)
{
try { _outlookApp.Quit(); } catch { }
}
try { Marshal.ReleaseComObject(_outlookApp); } catch { }
_outlookApp = null;
}
}
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
public class ProfileManager
{
private readonly string _dataDir;
private readonly string _profilesFile;
private readonly string _mappingsDir;
public ProfileManager()
{
_dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"StarfaceOutlookSync");
_mappingsDir = Path.Combine(_dataDir, "mappings");
_profilesFile = Path.Combine(_dataDir, "profiles.json");
Directory.CreateDirectory(_dataDir);
Directory.CreateDirectory(_mappingsDir);
}
public List<SyncProfile> GetProfiles()
{
try
{
if (!File.Exists(_profilesFile)) return new List<SyncProfile>();
var json = File.ReadAllText(_profilesFile);
return JsonConvert.DeserializeObject<List<SyncProfile>>(json) ?? new List<SyncProfile>();
}
catch { return new List<SyncProfile>(); }
}
public void SaveProfiles(List<SyncProfile> profiles)
{
var json = JsonConvert.SerializeObject(profiles, Formatting.Indented);
File.WriteAllText(_profilesFile, json);
}
public SyncProfile GetProfile(string id)
{
return GetProfiles().FirstOrDefault(p => p.Id == id);
}
public void AddProfile(SyncProfile profile)
{
var profiles = GetProfiles();
profiles.Add(profile);
SaveProfiles(profiles);
}
public void UpdateProfile(SyncProfile profile)
{
var profiles = GetProfiles();
var idx = profiles.FindIndex(p => p.Id == profile.Id);
if (idx >= 0)
{
profiles[idx] = profile;
SaveProfiles(profiles);
}
}
public void DeleteProfile(string id)
{
var profiles = GetProfiles().Where(p => p.Id != id).ToList();
SaveProfiles(profiles);
var mappingFile = Path.Combine(_mappingsDir, $"{id}.json");
if (File.Exists(mappingFile)) File.Delete(mappingFile);
}
public void UpdateLastSync(string profileId)
{
var profiles = GetProfiles();
var profile = profiles.FirstOrDefault(p => p.Id == profileId);
if (profile != null)
{
profile.LastSync = DateTime.Now.ToString("o");
SaveProfiles(profiles);
}
}
public List<SyncMapping> GetMappings(string profileId)
{
try
{
var file = Path.Combine(_mappingsDir, $"{profileId}.json");
if (!File.Exists(file)) return new List<SyncMapping>();
return JsonConvert.DeserializeObject<List<SyncMapping>>(File.ReadAllText(file))
?? new List<SyncMapping>();
}
catch { return new List<SyncMapping>(); }
}
public void SaveMappings(string profileId, List<SyncMapping> mappings)
{
var file = Path.Combine(_mappingsDir, $"{profileId}.json");
File.WriteAllText(file, JsonConvert.SerializeObject(mappings, Formatting.Indented));
}
public void AddOrUpdateMapping(SyncMapping mapping)
{
var mappings = GetMappings(mapping.ProfileId);
var existing = mappings.FindIndex(m =>
m.OutlookEntryId == mapping.OutlookEntryId || m.StarfaceId == mapping.StarfaceId);
if (existing >= 0)
mappings[existing] = mapping;
else
mappings.Add(mapping);
SaveMappings(mapping.ProfileId, mappings);
}
public string GenerateId()
{
return DateTime.Now.Ticks.ToString("x") + Guid.NewGuid().ToString("N").Substring(0, 8);
}
}
}
@@ -0,0 +1,488 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
public class StarfaceApiClient : IDisposable
{
private readonly HttpClient _http;
private readonly StarfaceConnection _connection;
private readonly string _baseUrl;
private string _token;
public StarfaceApiClient(StarfaceConnection connection)
{
_connection = connection;
var handler = new HttpClientHandler();
// Self-signed Zertifikate der Starface akzeptieren
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;
_http = new HttpClient(handler);
_http.DefaultRequestHeaders.Add("X-Version", "2");
_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}/rest";
}
private static string Sha512(string input)
{
using (var sha = SHA512.Create())
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha.ComputeHash(bytes);
var sb = new StringBuilder(128);
foreach (var b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
public async Task<bool> LoginAsync()
{
try
{
// Schritt 1: Nonce holen
var nonceResp = await _http.GetAsync($"{_baseUrl}/login");
if (!nonceResp.IsSuccessStatusCode) return false;
var nonceJson = JObject.Parse(await nonceResp.Content.ReadAsStringAsync());
var loginType = nonceJson["loginType"]?.ToString() ?? "Internal";
var nonce = nonceJson["nonce"]?.ToString() ?? "";
// Schritt 2: Secret berechnen
var passwordHash = Sha512(_connection.Password);
var combined = _connection.LoginId + nonce + passwordHash;
var combinedHash = Sha512(combined);
var secret = $"{_connection.LoginId}:{combinedHash}";
// Schritt 3: Login
var loginBody = new { loginType, nonce, secret };
var content = new StringContent(JsonConvert.SerializeObject(loginBody), Encoding.UTF8, "application/json");
var loginResp = await _http.PostAsync($"{_baseUrl}/login", content);
if (!loginResp.IsSuccessStatusCode) return false;
var tokenJson = JObject.Parse(await loginResp.Content.ReadAsStringAsync());
_token = tokenJson["token"]?.ToString();
if (!string.IsNullOrEmpty(_token))
{
_http.DefaultRequestHeaders.Remove("authToken");
_http.DefaultRequestHeaders.Add("authToken", _token);
}
return !string.IsNullOrEmpty(_token);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Starface login failed: {ex.Message}");
return false;
}
}
public async Task LogoutAsync()
{
if (string.IsNullOrEmpty(_token)) return;
try { await _http.DeleteAsync($"{_baseUrl}/login"); } catch { }
_token = null;
}
public async Task<string> GetCurrentUserIdAsync()
{
try
{
var resp = await _http.GetAsync($"{_baseUrl}/users/me");
if (!resp.IsSuccessStatusCode) return null;
var json = JObject.Parse(await resp.Content.ReadAsStringAsync());
return json["id"]?.ToString();
}
catch { return null; }
}
public async Task<List<StarfaceAddressBook>> GetAddressBooksAsync()
{
var books = new List<StarfaceAddressBook>();
// 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)
{
allTags = JArray.Parse(await resp.Content.ReadAsStringAsync());
OnDebug?.Invoke($"Gefundene Tags: {allTags.Count}");
foreach (var t in allTags)
OnDebug?.Invoke($" Tag: {t["name"]} (id: {t["id"]}, alias: {t["alias"]}, owner: {t["owner"]})");
}
}
catch { }
// Zentrales Adressbuch (folder/all)
var allTag = allTags.FirstOrDefault(t => t["name"]?.ToString() == "folder/all"
|| t["alias"]?.ToString()?.Contains("folder.all") == true);
books.Add(new StarfaceAddressBook
{
Type = "central",
TagId = allTag?["id"]?.ToString() ?? "",
Name = "Zentrales Adressbuch"
});
// Persoenliches Adressbuch (folder/private mit owner = userId)
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
var privateTag = allTags.FirstOrDefault(t =>
(t["name"]?.ToString() == "folder/private" || t["alias"]?.ToString()?.Contains("folder.private") == true)
&& t["owner"]?.ToString() == userId);
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
TagId = privateTag?["id"]?.ToString() ?? "",
Name = "Persoenliches Adressbuch"
});
}
// Alle weiteren Tags als Adressbuecher anbieten
foreach (var tag in allTags)
{
var tagName = tag["name"]?.ToString() ?? "";
// folder/all und folder/private bereits oben erfasst
if (tagName == "folder/all" || tagName == "folder/private") continue;
books.Add(new StarfaceAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = tagName
});
}
return books;
}
public event Action<string> OnDebug;
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
{
var contacts = new List<UnifiedContact>();
int page = 0;
const int pageSize = 200;
bool firstPage = true;
while (true)
{
var query = $"page={page}&pagesize={pageSize}";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query += $"&userId={book.UserId}";
if (book.Type == "tag" && !string.IsNullOrEmpty(book.TagId))
query += $"&tags={book.TagId}";
var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}");
if (!resp.IsSuccessStatusCode) break;
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;
}
if (array.Count == 0) break;
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
// Die Listen-API gibt nur Summary zurueck.
// Jeden Kontakt einzeln abrufen fuer alle Felder.
foreach (var item in array)
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
try
{
var detailResp = await _http.GetAsync($"{_baseUrl}/contacts/{id}");
if (detailResp.IsSuccessStatusCode)
{
var detailBody = await detailResp.Content.ReadAsStringAsync();
var detailObj = JObject.Parse(detailBody);
if (firstPage)
{
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{detailObj.ToString(Formatting.Indented)}");
firstPage = false;
}
contacts.Add(MapFromStarface(detailObj));
}
}
catch { }
}
if (array.Count < pageSize) break;
page++;
}
return contacts;
}
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
{
var sfContact = MapToStarface(contact);
// Tag zuweisen - die Starface verlangt dass jeder Kontakt einem Tag zugeordnet ist
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray
{
new JObject { ["id"] = book.TagId }
};
}
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var body = sfContact.ToString();
OnDebug?.Invoke($"POST /contacts{query} Body:\n{body}");
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)
{
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 body = sfContact.ToString();
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync();
OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
}
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteContactAsync(string contactId)
{
var resp = await _http.DeleteAsync($"{_baseUrl}/contacts/{contactId}");
return resp.IsSuccessStatusCode;
}
private UnifiedContact MapFromStarface(JToken item)
{
var contact = new UnifiedContact();
contact.StarfaceId = item["id"]?.ToString() ?? "";
// 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)
{
foreach (var block in blocks)
{
var blockAttrs = block["attributes"] as JArray;
if (blockAttrs == null) continue;
foreach (var attr in blockAttrs)
{
var name = attr["name"]?.ToString() ?? "";
var displayKey = attr["displayKey"]?.ToString() ?? "";
var val = attr["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(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;
}
}
}
}
// 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)
{
JArray MakeAttrs(params (string displayKey, string name, string value)[] fields)
{
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;
}
// 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"] = blocks
};
}
public void Dispose()
{
_http?.Dispose();
}
}
internal static class DictionaryExtensions
{
public static TValue GetValueOrDefault<TKey, TValue>(this Dictionary<TKey, TValue> dict, TKey key, TValue defaultValue)
{
return dict.TryGetValue(key, out var value) ? value : defaultValue;
}
}
}
@@ -0,0 +1,436 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.Services
{
public class SyncEngine
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly OutlookContactsService _outlookService = new OutlookContactsService();
public event Action<string> OnProgress;
private void Log(string message) => OnProgress?.Invoke(message);
/// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
/// auf der anderen auch gefuellt (und gleich) sein.
/// Ein leeres Feld auf einer Seite und ein gefuelltes auf der anderen
/// bedeutet: verschiedene Kontakte.
/// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
foreach (var c in candidates)
{
if (IsMatch(contact, c))
return c;
}
return null;
}
private static bool IsMatch(UnifiedContact a, UnifiedContact b)
{
// Mindestens ein identifizierendes Feld muss vorhanden sein
bool hasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName);
bool hasEmail = !string.IsNullOrEmpty(a.Email);
bool hasPhone = !string.IsNullOrEmpty(a.PhoneWork) || !string.IsNullOrEmpty(a.PhoneMobile);
if (!hasName && !hasEmail && !hasPhone) return false;
// E-Mail: wenn auf beiden Seiten vorhanden, muss sie gleich sein
// Wenn nur auf einer Seite vorhanden -> kein Match
if (!FieldsCompatible(a.Email, b.Email)) return false;
// Name: wenn auf einer Seite vorhanden, muss er gleich sein
if (!FieldsCompatible(a.FirstName, b.FirstName)) return false;
if (!FieldsCompatible(a.LastName, b.LastName)) return false;
// Firma: wenn auf einer Seite vorhanden, muss sie gleich sein
// Leere Firma vs. gefuellte Firma = verschiedene Kontakte
if (!FieldsCompatible(a.Company, b.Company)) return false;
// Telefon/Fax: wenn auf einer Seite vorhanden, muss es gleich sein
if (!PhoneFieldsCompatible(a.PhoneWork, b.PhoneWork)) return false;
if (!PhoneFieldsCompatible(a.PhoneMobile, b.PhoneMobile)) return false;
if (!PhoneFieldsCompatible(a.PhoneHome, b.PhoneHome)) return false;
if (!PhoneFieldsCompatible(a.Fax, b.Fax)) return false;
// Mindestens ein starkes Match muss vorhanden sein
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& a.FirstName.Equals(b.FirstName, StringComparison.OrdinalIgnoreCase)
&& a.LastName.Equals(b.LastName, StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName));
bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork)
&& NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork))
|| (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax)
&& NormalizePhone(a.Fax) == NormalizePhone(b.Fax));
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Email oder Name reicht. Telefon/Fax nur mit Firma zusammen.
return emailMatch || nameMatch || (phoneMatch && companyMatch) || (companyMatch && phoneMatch);
}
/// <summary>
/// Prueft ob zwei Felder kompatibel sind.
/// Beide leer = kompatibel. Beide gleich = kompatibel.
/// Eins leer, eins gefuellt = NICHT kompatibel (verschiedene Kontakte).
/// </summary>
private static bool FieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false; // Einer leer, anderer nicht
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
private static bool PhoneFieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false;
return NormalizePhone(a) == NormalizePhone(b);
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
{
ProfileName = profile.Name,
Timestamp = DateTime.Now.ToString("o")
};
try
{
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
{
starface.OnDebug += (msg) => Log(msg);
var loginOk = await starface.LoginAsync();
if (!loginOk)
{
result.ErrorMessages.Add("Starface-Login fehlgeschlagen");
result.Errors++;
return result;
}
// 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);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Bestehende Mappings laden
var mappings = _profileManager.GetMappings(profile.Id);
// 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)
{
// In Outlook geloescht -> in Starface auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
if (await starface.DeleteContactAsync(mapping.StarfaceId))
{
result.Updated++;
Log($" Geloescht (OL->SF): {sc.DisplayName}");
}
}
else
{
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping);
}
continue;
}
if (oc != null && sc == null)
{
// In Starface geloescht -> in Outlook auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
if (_outlookService.DeleteContact(mapping.OutlookEntryId))
{
result.Updated++;
Log($" Geloescht (SF->OL): {oc.DisplayName}");
}
}
else
{
// Richtung erlaubt kein Loeschen -> Mapping behalten
newMappings.Add(mapping);
}
continue;
}
if (oc != null && sc != null)
{
// Beide vorhanden -> auf Aenderungen pruefen
var olHash = oc.GetHash();
var sfHash = sc.GetHash();
bool olChanged = olHash != mapping.LastSyncHash;
bool sfChanged = sfHash != mapping.LastSyncHash;
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{
// Outlook hat sich geaendert -> Starface updaten
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{
mapping.LastSyncHash = olHash;
result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
}
}
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{
// Starface hat sich geaendert -> Outlook updaten
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
{
mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
}
}
else if (olChanged && sfChanged)
{
// Beide geaendert -> Konflikt, neuere gewinnt (Outlook bevorzugt)
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{
mapping.LastSyncHash = olHash;
result.Updated++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
}
}
else
{
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
{
mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
}
}
}
// Beide unveraendert -> nichts tun
}
newMappings.Add(mapping);
}
// ============================================
// Phase 2: Neue Outlook-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
if (unmappedOutlook.Count > 0)
Log($"Neue Outlook-Kontakte: {unmappedOutlook.Count}");
// Starface-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
foreach (var oc in unmappedOutlook)
{
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash()
});
processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match);
result.Updated++;
Log($" Verknuepft (OL->SF): {oc.DisplayName}");
}
}
else
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}");
}
else
{
Log($" FEHLER: Kontakt konnte nicht erstellt werden: {oc.DisplayName}");
result.Errors++;
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"OL->SF {oc.DisplayName}: {ex.Message}");
}
}
}
// ============================================
// Phase 3: Neue Starface-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
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
{
// Duplikat-Check: existiert der Kontakt schon in Outlook?
var match = FindMatch(sc, unmappedOutlook);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
if (_outlookService.UpdateContact(match.OutlookEntryId, sc))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match);
result.Updated++;
Log($" Verknuepft (SF->OL): {sc.DisplayName}");
}
}
else
{
// Neu -> in Outlook erstellen
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Created++;
Log($" Erstellt (SF->OL): {sc.DisplayName}");
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"SF->OL {sc.DisplayName}: {ex.Message}");
}
}
}
// Mappings speichern
_profileManager.SaveMappings(profile.Id, newMappings);
_profileManager.UpdateLastSync(profile.Id);
await starface.LogoutAsync();
Log($"Fertig: {result.Created} erstellt, {result.Updated} aktualisiert, {result.Errors} Fehler");
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"Allgemeiner Fehler: {ex.Message}");
}
return result;
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyTitle>Starface Outlook Sync</AssemblyTitle>
<Company>HackerSoft - Hacker-Net Telekommunikation</Company>
<Product>Starface Outlook Sync</Product>
<Version>0.0.0.22</Version>
<AssemblyVersion>0.0.0.22</AssemblyVersion>
<FileVersion>0.0.0.22</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
+76
View File
@@ -0,0 +1,76 @@
using System.Drawing;
using System.Windows.Forms;
namespace StarfaceOutlookSync.UI
{
public class AboutForm : Form
{
public AboutForm()
{
Text = "Info";
Size = new Size(360, 280);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
BackColor = Color.White;
var lblProduct = new Label
{
Text = "Outlook-SYNC \u2194 Starface",
Left = 0, Top = 24, Width = 340, Height = 30,
TextAlign = ContentAlignment.MiddleCenter,
Font = new Font("Segoe UI", 14, FontStyle.Bold),
ForeColor = Color.FromArgb(0, 120, 212)
};
var lblVersion = new Label
{
Text = "Version 0.0.0.22",
Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray
};
var separator = new Label
{
Left = 40, Top = 86, Width = 260, Height = 1,
BorderStyle = BorderStyle.Fixed3D
};
var lblCompany = new Label
{
Text = "HackerSoft",
Left = 0, Top = 100, Width = 340, Height = 24,
TextAlign = ContentAlignment.MiddleCenter,
Font = new Font("Segoe UI", 11, FontStyle.Bold)
};
var lblSubtitle = new Label
{
Text = "Hacker-Net Telekommunikation",
Left = 0, Top = 124, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray
};
var lblAddress = new Label
{
Text = "Stefan Hacker\r\nAm Wunderburgpark 5b\r\n26135 Oldenburg",
Left = 0, Top = 152, Width = 340, Height = 56,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.FromArgb(96, 94, 92)
};
var btnOk = new Button
{
Text = "OK", Left = 130, Top = 212, Width = 80, Height = 28,
DialogResult = DialogResult.OK
};
Controls.AddRange(new Control[] { lblProduct, lblVersion, separator, lblCompany, lblSubtitle, lblAddress, btnOk });
AcceptButton = btnOk;
}
}
}
+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);
}
}
}
}
+434
View File
@@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
using Timer = System.Timers.Timer;
namespace StarfaceOutlookSync.UI
{
public class MainForm : Form
{
private readonly ProfileManager _profileManager = new ProfileManager();
private readonly SyncEngine _syncEngine = new SyncEngine();
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
private volatile bool _syncRunning = false;
public MainForm()
{
InitializeComponent();
SetupTrayIcon();
SetupAutoSync();
RefreshProfileList();
// Einstellungen laden und anwenden
var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
if (settings.StartMinimized)
{
WindowState = FormWindowState.Minimized;
ShowInTaskbar = false;
Visible = false;
}
// Beim Start automatisch synchronisieren
if (settings.SyncOnStart)
{
_ = SyncAllProfiles();
}
}
private async Task SyncAllProfiles()
{
var profiles = _profileManager.GetProfiles().Where(p => p.Enabled).ToList();
foreach (var profile in profiles)
{
await RunSync(profile);
}
}
protected override void SetVisibleCore(bool value)
{
// Beim ersten Anzeigen pruefen ob minimiert gestartet werden soll
if (!IsHandleCreated)
{
var settings = UserSettings.Load();
if (settings.StartMinimized)
{
CreateHandle();
value = false;
}
}
base.SetVisibleCore(value);
}
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(830, 450);
MinimumSize = new Size(830, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
// Profil-Liste
_profileList = new ListView
{
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
GridLines = true,
MultiSelect = false
};
_profileList.Columns.Add("Profil", 150);
_profileList.Columns.Add("Starface", 130);
_profileList.Columns.Add("Outlook-Ordner", 130);
_profileList.Columns.Add("Richtung", 90);
_profileList.Columns.Add("Letzte Sync", 130);
_profileList.DoubleClick += (s, e) => EditProfile();
// Button-Panel
var buttonPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 45,
Padding = new Padding(8, 8, 8, 4),
FlowDirection = FlowDirection.LeftToRight
};
_btnNew = new Button { Text = "Neues Profil", Width = 100, Height = 30 };
_btnNew.Click += (s, e) => NewProfile();
_btnEdit = new Button { Text = "Bearbeiten", Width = 90, Height = 30 };
_btnEdit.Click += (s, e) => EditProfile();
_btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 };
_btnDelete.Click += (s, e) => DeleteProfile();
_btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_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();
_btnInfo = new Button { Text = "Info", Width = 50, Height = 30 };
_btnInfo.Click += (s, e) => ShowAbout();
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
_statusLabel = new ToolStripStatusLabel("Bereit");
_statusBar.Items.Add(_statusLabel);
Controls.Add(_profileList);
Controls.Add(buttonPanel);
Controls.Add(_statusBar);
}
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("-");
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled))
{
var profile = p;
_trayMenu.Items.Add($"Sync: {profile.Name}", null, async (s, e) =>
{
await RunSync(profile);
});
}
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());
}
private void SetupAutoSync()
{
_autoSyncTimer = new Timer(60000); // Jede Minute pruefen
_autoSyncTimer.Elapsed += async (s, e) => await CheckAutoSync();
_autoSyncTimer.Start();
}
private async Task CheckAutoSync()
{
var profiles = _profileManager.GetProfiles();
foreach (var profile in profiles.Where(p => p.Enabled && p.AutoSyncIntervalMinutes > 0))
{
if (string.IsNullOrEmpty(profile.LastSync))
{
await RunSync(profile);
continue;
}
if (DateTime.TryParse(profile.LastSync, out var lastSync))
{
if (DateTime.Now - lastSync > TimeSpan.FromMinutes(profile.AutoSyncIntervalMinutes))
{
await RunSync(profile);
}
}
}
}
private void ShowMainWindow()
{
Show();
WindowState = FormWindowState.Normal;
BringToFront();
RefreshProfileList();
}
private void RefreshProfileList()
{
_profileList.Items.Clear();
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles)
{
var dirText = p.SyncDirection == SyncDirection.Both ? "Bidirektional" :
p.SyncDirection == SyncDirection.OutlookToStarface ? "OL -> SF" : "SF -> OL";
var lastSync = string.IsNullOrEmpty(p.LastSync) ? "Noch nie" :
DateTime.TryParse(p.LastSync, out var dt) ? dt.ToString("dd.MM.yyyy HH:mm") : p.LastSync;
var item = new ListViewItem(new[]
{
p.Name,
$"{p.StarfaceConnection.Host} / {p.StarfaceAddressBook.Name}",
p.OutlookFolderName,
dirText,
lastSync
});
item.Tag = p;
if (!p.Enabled)
item.ForeColor = Color.Gray;
_profileList.Items.Add(item);
}
// Tray-Menu aktualisieren
UpdateTrayMenu();
}
private void NewProfile()
{
using (var editor = new ProfileEditorForm(null))
{
if (editor.ShowDialog(this) == DialogResult.OK)
RefreshProfileList();
}
}
private void EditProfile()
{
if (_profileList.SelectedItems.Count == 0) return;
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
using (var editor = new ProfileEditorForm(profile))
{
if (editor.ShowDialog(this) == DialogResult.OK)
RefreshProfileList();
}
}
private void DeleteProfile()
{
if (_profileList.SelectedItems.Count == 0) return;
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
if (MessageBox.Show($"Profil '{profile.Name}' wirklich loeschen?",
"Profil loeschen", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
_profileManager.DeleteProfile(profile.Id);
RefreshProfileList();
}
}
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)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return Task.CompletedTask;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return Task.CompletedTask;
using (var syncForm = new SyncProgressForm(profile))
{
syncForm.ShowDialog(this);
}
RefreshProfileList();
return Task.CompletedTask;
}
private async Task RunSync(SyncProfile profile)
{
if (_syncRunning)
{
SetStatus("Sync laeuft bereits, bitte warten...");
return;
}
_syncRunning = true;
try
{
SetStatus($"Synchronisiere '{profile.Name}'...");
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
var result = await _syncEngine.SyncProfileAsync(profile);
var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert";
if (result.Errors > 0) msg += $", {result.Errors} Fehler";
_trayIcon.ShowBalloonTip(3000, "Starface Sync", msg,
result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info);
SetStatus(msg);
}
catch (Exception ex)
{
_trayIcon.ShowBalloonTip(3000, "Starface Sync Fehler",
ex.Message, ToolTipIcon.Error);
SetStatus($"Fehler: {ex.Message}");
}
finally
{
_syncRunning = false;
}
}
private void SetStatus(string text)
{
if (InvokeRequired)
Invoke(new Action(() => _statusLabel.Text = text));
else
_statusLabel.Text = text;
}
private void ShowSettings()
{
using (var settings = new SettingsForm())
{
settings.ShowDialog(this);
}
}
private void ShowAbout()
{
using (var about = new AboutForm())
{
about.ShowDialog(this);
}
}
private void ExitApplication()
{
_autoSyncTimer?.Stop();
_trayIcon.Visible = false;
_trayIcon.Dispose();
Application.Exit();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide(); // In den Tray minimieren
_trayIcon.ShowBalloonTip(2000, "Starface Kontakt-Sync",
"Laeuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon fuer Optionen.",
ToolTipIcon.Info);
}
else
{
base.OnFormClosing(e);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_autoSyncTimer?.Dispose();
_trayIcon?.Dispose();
_trayMenu?.Dispose();
}
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,125 @@
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Collections.Generic;
namespace StarfaceOutlookSync.UI
{
public class OutlookFolderBrowserForm : Form
{
private TreeView _tree;
private Button _btnOk, _btnCancel;
public string SelectedFolderPath { get; private set; } = "";
public string SelectedFolderName { get; private set; } = "";
public OutlookFolderBrowserForm(List<string> folderPaths, string currentSelection)
{
InitializeComponent();
BuildTree(folderPaths, currentSelection);
}
private void InitializeComponent()
{
Text = "Outlook-Ordner waehlen";
Size = new Size(400, 450);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
var lblInfo = new Label
{
Text = "Bitte waehlen Sie den Outlook-Kontaktordner fuer die Synchronisation:",
Left = 12, Top = 12, Width = 360, Height = 36
};
_tree = new TreeView
{
Left = 12, Top = 52, Width = 360, Height = 310,
HideSelection = false,
ShowLines = true,
ShowPlusMinus = true,
ShowRootLines = true
};
_tree.DoubleClick += (s, e) => { if (_tree.SelectedNode?.Tag != null) SelectAndClose(); };
_btnOk = new Button
{
Text = "OK", Left = 210, Top = 372, Width = 75, Height = 28,
DialogResult = DialogResult.None
};
_btnOk.Click += (s, e) => SelectAndClose();
_btnCancel = new Button
{
Text = "Abbrechen", Left = 292, Top = 372, Width = 80, Height = 28,
DialogResult = DialogResult.Cancel
};
Controls.AddRange(new Control[] { lblInfo, _tree, _btnOk, _btnCancel });
AcceptButton = _btnOk;
CancelButton = _btnCancel;
}
private void BuildTree(List<string> folderPaths, string currentSelection)
{
_tree.Nodes.Clear();
var nodeMap = new Dictionary<string, TreeNode>();
foreach (var path in folderPaths)
{
// Pfad: \\Store\Kontakte\Unterordner
var parts = path.TrimStart('\\').Split('\\');
string runningPath = "";
TreeNode parent = null;
for (int i = 0; i < parts.Length; i++)
{
runningPath += "\\" + parts[i];
if (!nodeMap.ContainsKey(runningPath))
{
var node = new TreeNode(parts[i]);
// Nur Blatt-Knoten oder bekannte Pfade sind waehlbar
if (i == parts.Length - 1)
node.Tag = path; // Vollstaendiger Pfad
if (parent == null)
_tree.Nodes.Add(node);
else
parent.Nodes.Add(node);
nodeMap[runningPath] = node;
// Aktuellen Ordner vorauswaehlen
if (path == currentSelection)
{
_tree.SelectedNode = node;
node.EnsureVisible();
}
}
parent = nodeMap[runningPath];
}
}
_tree.ExpandAll();
}
private void SelectAndClose()
{
if (_tree.SelectedNode?.Tag == null)
{
MessageBox.Show("Bitte einen Kontaktordner waehlen.", "Hinweis",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
SelectedFolderPath = _tree.SelectedNode.Tag.ToString();
SelectedFolderName = _tree.SelectedNode.Text;
DialogResult = DialogResult.OK;
Close();
}
}
}
@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
public class ProfileEditorForm : Form
{
private readonly ProfileManager _pm = new ProfileManager();
private readonly SyncProfile _existingProfile;
private readonly bool _isNew;
// Controls
private TextBox _txtName, _txtHost, _txtPort, _txtLoginId, _txtPassword, _txtOutlookFolder;
private CheckBox _chkSsl, _chkEnabled;
private ComboBox _cmbAddressBook, _cmbDirection;
private Button _btnBrowseFolder;
private NumericUpDown _numAutoSync;
private Button _btnTest, _btnLoadBooks, _btnSave, _btnCancel;
private Label _lblTestResult;
private List<StarfaceAddressBook> _addressBooks = new List<StarfaceAddressBook>();
private List<string> _outlookFolderPaths = new List<string>();
private string _selectedOutlookPath = "";
private string _selectedOutlookName = "";
public ProfileEditorForm(SyncProfile profile)
{
_existingProfile = profile;
_isNew = profile == null;
InitializeComponent();
LoadData();
}
private void InitializeComponent()
{
Text = _isNew ? "Neues Sync-Profil" : "Profil bearbeiten";
Size = new Size(480, 620);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
var panel = new Panel { Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(16) };
int y = 8;
// Profilname
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;
panel.Controls.Add(MakeLabel("Host / IP-Adresse:", 12, y)); y += 22;
_txtHost = new TextBox { Left = 12, Top = y, Width = 320 };
_txtPort = new TextBox { Left = 340, Top = y, Width = 60, Text = "443" };
panel.Controls.Add(_txtHost);
panel.Controls.Add(MakeLabel("Port:", 340, y - 22));
panel.Controls.Add(_txtPort); y += 32;
_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;
_txtLoginId = new TextBox { Left = 12, Top = y, Width = 200 }; panel.Controls.Add(_txtLoginId); y += 32;
panel.Controls.Add(MakeLabel("Kennwort:", 12, y)); y += 22;
_txtPassword = new TextBox { Left = 12, Top = y, Width = 200, UseSystemPasswordChar = true };
panel.Controls.Add(_txtPassword); y += 32;
_btnTest = new Button { Text = "Verbindung testen", Left = 12, Top = y, Width = 130, Height = 28 };
_btnTest.Click += async (s, e) => await TestConnection();
_btnLoadBooks = new Button { Text = "Adressbuecher laden", Left = 150, Top = y, Width = 140, Height = 28 };
_btnLoadBooks.Click += async (s, e) => await LoadAddressBooks();
panel.Controls.Add(_btnTest);
panel.Controls.Add(_btnLoadBooks); y += 32;
_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;
_cmbAddressBook = new ComboBox { Left = 12, Top = y, Width = 420, DropDownStyle = ComboBoxStyle.DropDownList };
panel.Controls.Add(_cmbAddressBook); y += 32;
// === Outlook ===
panel.Controls.Add(MakeSectionLabel("Outlook-Einstellungen", 12, y)); y += 26;
panel.Controls.Add(MakeLabel("Kontakte-Ordner:", 12, y)); y += 22;
_txtOutlookFolder = new TextBox { Left = 12, Top = y, Width = 330, ReadOnly = true, BackColor = SystemColors.Window };
_btnBrowseFolder = new Button { Text = "Durchsuchen...", Left = 348, Top = y - 1, Width = 84, Height = 24 };
_btnBrowseFolder.Click += (s, e) => BrowseOutlookFolder();
panel.Controls.Add(_txtOutlookFolder);
panel.Controls.Add(_btnBrowseFolder); y += 32;
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.SelectedIndex = 0;
panel.Controls.Add(_cmbDirection); y += 32;
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 };
panel.Controls.Add(_numAutoSync); y += 32;
_chkEnabled = new CheckBox { Text = "Profil aktiviert", Left = 12, Top = y, Checked = true, AutoSize = true };
panel.Controls.Add(_chkEnabled); y += 36;
// Buttons
_btnSave = new Button { Text = "Speichern", Left = 12, Top = y, Width = 100, Height = 30, DialogResult = DialogResult.None };
_btnSave.Click += (s, e) => SaveProfile();
_btnCancel = new Button { Text = "Abbrechen", Left = 120, Top = y, Width = 100, Height = 30, DialogResult = DialogResult.Cancel };
panel.Controls.Add(_btnSave);
panel.Controls.Add(_btnCancel);
Controls.Add(panel);
AcceptButton = _btnSave;
CancelButton = _btnCancel;
}
private Label MakeLabel(string text, int x, int y)
{
return new Label { Text = text, Left = x, Top = y, AutoSize = true };
}
private Label MakeSectionLabel(string text, int x, int y)
{
return new Label { Text = text, Left = x, Top = y, AutoSize = true, Font = new Font("Segoe UI", 10, FontStyle.Bold) };
}
private void LoadData()
{
// Outlook-Ordner laden
try
{
using (var outlook = new OutlookContactsService())
{
_outlookFolderPaths = outlook.GetContactFolderPaths();
}
}
catch (Exception ex)
{
MessageBox.Show(
$"Outlook-Kontaktordner konnten nicht geladen werden:\n{ex.Message}\n\nIst Outlook gestartet?",
"Outlook-Verbindung", MessageBoxButtons.OK, MessageBoxIcon.Warning);
_outlookFolderPaths = new List<string>();
}
// Bestehende Werte laden
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;
_chkEnabled.Checked = _existingProfile.Enabled;
_numAutoSync.Value = _existingProfile.AutoSyncIntervalMinutes;
_cmbDirection.SelectedIndex = (int)_existingProfile.SyncDirection;
// Outlook-Ordner
_selectedOutlookPath = _existingProfile.OutlookFolderPath;
_selectedOutlookName = _existingProfile.OutlookFolderName;
_txtOutlookFolder.Text = _selectedOutlookPath;
// Adressbuch
if (_existingProfile.StarfaceAddressBook != null)
{
_addressBooks.Add(_existingProfile.StarfaceAddressBook);
_cmbAddressBook.Items.Add(_existingProfile.StarfaceAddressBook.Name);
_cmbAddressBook.SelectedIndex = 0;
}
}
else if (_outlookFolderPaths.Count > 0)
{
// Standard-Ordner vorauswaehlen
_selectedOutlookPath = _outlookFolderPaths[0];
_selectedOutlookName = _selectedOutlookPath.Substring(_selectedOutlookPath.LastIndexOf('\\') + 1);
_txtOutlookFolder.Text = _selectedOutlookPath;
}
}
private void BrowseOutlookFolder()
{
using (var browser = new OutlookFolderBrowserForm(_outlookFolderPaths, _selectedOutlookPath))
{
if (browser.ShowDialog(this) == DialogResult.OK)
{
_selectedOutlookPath = browser.SelectedFolderPath;
_selectedOutlookName = browser.SelectedFolderName;
_txtOutlookFolder.Text = _selectedOutlookPath;
}
}
}
private StarfaceConnection GetConnection()
{
return new StarfaceConnection
{
Host = _txtHost.Text.Trim(),
Port = int.TryParse(_txtPort.Text, out var p) ? p : 443,
UseSsl = _chkSsl.Checked,
LoginId = _txtLoginId.Text.Trim(),
Password = _txtPassword.Text
};
}
private async Task TestConnection()
{
_lblTestResult.Text = "Teste Verbindung...";
_lblTestResult.ForeColor = Color.Gray;
_btnTest.Enabled = false;
try
{
using (var client = new StarfaceApiClient(GetConnection()))
{
var ok = await client.LoginAsync();
if (ok)
{
_lblTestResult.Text = "Verbindung erfolgreich!";
_lblTestResult.ForeColor = Color.Green;
await client.LogoutAsync();
}
else
{
_lblTestResult.Text = "Login fehlgeschlagen. Zugangsdaten pruefen.";
_lblTestResult.ForeColor = Color.Red;
}
}
}
catch (Exception ex)
{
_lblTestResult.Text = $"Fehler: {ex.Message}";
_lblTestResult.ForeColor = Color.Red;
}
_btnTest.Enabled = true;
}
private async Task LoadAddressBooks()
{
_lblTestResult.Text = "Lade Adressbuecher...";
_lblTestResult.ForeColor = Color.Gray;
_btnLoadBooks.Enabled = false;
try
{
using (var client = new StarfaceApiClient(GetConnection()))
{
var ok = await client.LoginAsync();
if (!ok)
{
_lblTestResult.Text = "Login fehlgeschlagen.";
_lblTestResult.ForeColor = Color.Red;
_btnLoadBooks.Enabled = true;
return;
}
_addressBooks = await client.GetAddressBooksAsync();
await client.LogoutAsync();
_cmbAddressBook.Items.Clear();
foreach (var book in _addressBooks)
_cmbAddressBook.Items.Add(book.Name);
if (_addressBooks.Count > 0)
{
_cmbAddressBook.SelectedIndex = 0;
_lblTestResult.Text = $"{_addressBooks.Count} Adressbuch/buecher gefunden.";
_lblTestResult.ForeColor = Color.Green;
}
else
{
_lblTestResult.Text = "Keine Adressbuecher gefunden.";
_lblTestResult.ForeColor = Color.Orange;
}
}
}
catch (Exception ex)
{
_lblTestResult.Text = $"Fehler: {ex.Message}";
_lblTestResult.ForeColor = Color.Red;
}
_btnLoadBooks.Enabled = true;
}
private void SaveProfile()
{
if (string.IsNullOrWhiteSpace(_txtName.Text))
{
MessageBox.Show("Bitte Profilnamen eingeben.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (string.IsNullOrWhiteSpace(_txtHost.Text))
{
MessageBox.Show("Bitte Starface-Host eingeben.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (_cmbAddressBook.SelectedIndex < 0 || _addressBooks.Count == 0)
{
MessageBox.Show("Bitte ein Starface-Adressbuch auswaehlen.\nZuerst 'Adressbuecher laden' klicken.",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (string.IsNullOrEmpty(_selectedOutlookPath))
{
MessageBox.Show("Bitte einen Outlook-Kontaktordner waehlen.",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var profile = new SyncProfile
{
Id = _existingProfile?.Id ?? _pm.GenerateId(),
Name = _txtName.Text.Trim(),
StarfaceConnection = GetConnection(),
StarfaceAddressBook = _addressBooks[_cmbAddressBook.SelectedIndex],
OutlookFolderPath = _selectedOutlookPath,
OutlookFolderName = _selectedOutlookName,
SyncDirection = (SyncDirection)_cmbDirection.SelectedIndex,
Enabled = _chkEnabled.Checked,
AutoSyncIntervalMinutes = (int)_numAutoSync.Value,
LastSync = _existingProfile?.LastSync ?? ""
};
if (_isNew)
{
_pm.AddProfile(profile);
}
else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.StarfaceAddressBook?.TagId != profile.StarfaceAddressBook?.TagId
|| _existingProfile.StarfaceAddressBook?.Type != profile.StarfaceAddressBook?.Type)
{
_pm.SaveMappings(profile.Id, new List<SyncMapping>());
profile.LastSync = "";
MessageBox.Show(
"Adressbuch wurde geaendert.\nSync-Zuordnungen wurden automatisch zurueckgesetzt.",
"Adressbuch geaendert", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
_pm.UpdateProfile(profile);
}
DialogResult = DialogResult.OK;
Close();
}
}
}
@@ -0,0 +1,87 @@
using System.Drawing;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.UI
{
public class SettingsForm : Form
{
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private Button _btnSave, _btnCancel;
private readonly UserSettings _settings;
public SettingsForm()
{
_settings = UserSettings.Load();
InitializeComponent();
}
private void InitializeComponent()
{
Text = "Einstellungen";
Size = new Size(380, 250);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
_chkStartMinimized = new CheckBox
{
Text = "Minimiert starten (nur im System Tray)",
Left = 20, Top = 24, AutoSize = true,
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 lblHint = new Label
{
Text = "Hinweis: Outlook muss nach Aenderung dieser Option\nneu gestartet werden.",
Left = 38, Top = 102, Width = 300, Height = 32,
ForeColor = Color.Gray,
Font = new Font("Segoe UI", 8)
};
_btnSave = new Button
{
Text = "Speichern", Left = 95, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.None
};
_btnSave.Click += (s, e) => Save();
_btnCancel = new Button
{
Text = "Abbrechen", Left = 189, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel
};
Size = new Size(380, 260);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel });
AcceptButton = _btnSave;
CancelButton = _btnCancel;
}
private void Save()
{
_settings.StartMinimized = _chkStartMinimized.Checked;
_settings.SyncOnStart = _chkSyncOnStart.Checked;
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
_settings.Save();
DialogResult = DialogResult.OK;
Close();
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
using StarfaceOutlookSync.Services;
namespace StarfaceOutlookSync.UI
{
public class SyncProgressForm : Form
{
private readonly SyncProfile _profile;
private readonly SyncEngine _engine = new SyncEngine();
private TextBox _txtLog;
private ProgressBar _progressBar;
private Button _btnClose, _btnStart;
private Label _lblResult;
public SyncProgressForm(SyncProfile profile)
{
_profile = profile;
InitializeComponent();
}
private void InitializeComponent()
{
Text = $"Synchronisation - {_profile.Name}";
Size = new Size(500, 400);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9);
var infoLabel = new Label
{
Text = $"{_profile.StarfaceConnection.Host} ({_profile.StarfaceAddressBook.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.Blocks,
Value = 0
};
_txtLog = new TextBox
{
Left = 12, Top = 68, Width = 460, Height = 200,
Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical,
BackColor = Color.FromArgb(30, 30, 30), ForeColor = Color.FromArgb(212, 212, 212),
Font = new Font("Consolas", 9)
};
_lblResult = new Label
{
Left = 12, Top = 276, Width = 460, Height = 40,
AutoSize = false, ForeColor = Color.Gray
};
_btnStart = new Button
{
Text = "Synchronisation starten", Left = 12, Top = 322, Width = 180, Height = 30
};
_btnStart.Click += async (s, e) => await RunSync();
_btnClose = new Button
{
Text = "Schliessen", Left = 380, Top = 322, Width = 90, Height = 30,
DialogResult = DialogResult.Cancel
};
Controls.AddRange(new Control[] { infoLabel, _progressBar, _txtLog, _lblResult, _btnStart, _btnClose });
CancelButton = _btnClose;
}
private void AppendLog(string message)
{
if (InvokeRequired)
{
Invoke(new Action(() => AppendLog(message)));
return;
}
_txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
}
private async Task RunSync()
{
_btnStart.Enabled = false;
_btnClose.Enabled = false;
_progressBar.Style = ProgressBarStyle.Marquee;
_lblResult.Text = "";
_engine.OnProgress += AppendLog;
try
{
var result = await Task.Run(() => _engine.SyncProfileAsync(_profile));
_progressBar.Style = ProgressBarStyle.Blocks;
_progressBar.Value = 100;
var resultText = $"Erstellt: {result.Created} | Aktualisiert: {result.Updated} | Fehler: {result.Errors}";
_lblResult.Text = resultText;
_lblResult.ForeColor = result.Errors > 0 ? Color.OrangeRed : Color.Green;
if (result.ErrorMessages.Count > 0)
{
AppendLog("--- Fehler ---");
foreach (var err in result.ErrorMessages)
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;
}
}
}
-109
View File
@@ -1,109 +0,0 @@
/** Unified contact model used for sync between Outlook and Starface */
export interface UnifiedContact {
id?: string;
outlookId?: string;
starfaceId?: string;
firstName: string;
lastName: string;
company: string;
jobTitle: string;
email: string;
emailSecondary: string;
phoneWork: string;
phoneMobile: string;
phoneHome: string;
fax: string;
street: string;
city: string;
postalCode: string;
state: string;
country: string;
website: string;
notes: string;
salutation: string;
title: string;
birthday: string;
lastModified?: string;
}
export function emptyContact(): UnifiedContact {
return {
firstName: "",
lastName: "",
company: "",
jobTitle: "",
email: "",
emailSecondary: "",
phoneWork: "",
phoneMobile: "",
phoneHome: "",
fax: "",
street: "",
city: "",
postalCode: "",
state: "",
country: "",
website: "",
notes: "",
salutation: "",
title: "",
birthday: "",
};
}
/** Starface connection settings */
export interface StarfaceConnection {
host: string;
port: number;
useSsl: boolean;
loginId: string;
password: string;
}
/** A Starface address book (represented by userId scope or tag) */
export interface StarfaceAddressBook {
type: "central" | "user" | "tag";
userId?: string;
tagId?: string;
name: string;
}
/** An Outlook contact folder */
export interface OutlookContactFolder {
id: string;
displayName: string;
parentFolderId?: string;
}
/** A sync profile mapping one Outlook folder to one Starface address book */
export interface SyncProfile {
id: string;
name: string;
starfaceConnection: StarfaceConnection;
starfaceAddressBook: StarfaceAddressBook;
outlookFolderId: string;
outlookFolderName: string;
syncDirection: "both" | "outlook-to-starface" | "starface-to-outlook";
lastSync?: string;
enabled: boolean;
}
/** Tracks which contacts have been synced to detect changes */
export interface SyncMapping {
profileId: string;
outlookId: string;
starfaceId: string;
lastSyncHash: string;
}
/** Result of a sync operation */
export interface SyncResult {
profileId: string;
profileName: string;
timestamp: string;
created: number;
updated: number;
deleted: number;
errors: string[];
direction: string;
}
-246
View File
@@ -1,246 +0,0 @@
import { UnifiedContact, OutlookContactFolder, emptyContact } from "../models/types";
/**
* Outlook contacts service using Office.js REST API and EWS.
* Works in both classic and new Outlook via Office.js mailbox REST API.
*/
export class OutlookContactsService {
private accessToken: string | null = null;
/** Get REST API access token from Office.js */
async getAccessToken(): Promise<string> {
return new Promise((resolve, reject) => {
if (typeof Office === "undefined" || !Office.context?.mailbox) {
reject(new Error("Office.js nicht verfügbar"));
return;
}
Office.context.mailbox.getCallbackTokenAsync(
{ isRest: true },
(result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
this.accessToken = result.value;
resolve(result.value);
} else {
reject(new Error(result.error?.message || "Token-Fehler"));
}
}
);
});
}
private getRestUrl(): string {
if (!Office.context?.mailbox?.restUrl) {
return "https://outlook.office.com/api/v2.0";
}
return Office.context.mailbox.restUrl + "/v2.0";
}
private async fetchApi(endpoint: string, options: RequestInit = {}): Promise<Response> {
if (!this.accessToken) {
await this.getAccessToken();
}
const url = `${this.getRestUrl()}${endpoint}`;
return fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
...((options.headers as Record<string, string>) || {}),
},
});
}
async getContactFolders(): Promise<OutlookContactFolder[]> {
try {
const resp = await this.fetchApi("/me/contactfolders");
if (!resp.ok) {
console.error("Failed to get contact folders:", resp.status);
return [];
}
const data = await resp.json();
const folders: OutlookContactFolder[] = [
{ id: "default", displayName: "Kontakte (Standard)" },
];
for (const f of data.value || []) {
folders.push({
id: f.Id,
displayName: f.DisplayName,
parentFolderId: f.ParentFolderId,
});
}
return folders;
} catch (err) {
console.error("Error getting contact folders:", err);
return [{ id: "default", displayName: "Kontakte (Standard)" }];
}
}
async getContacts(folderId: string): Promise<UnifiedContact[]> {
const allContacts: UnifiedContact[] = [];
let nextLink: string | null = null;
const pageSize = 100;
const basePath =
folderId === "default"
? `/me/contacts?$top=${pageSize}`
: `/me/contactfolders/${folderId}/contacts?$top=${pageSize}`;
let endpoint = basePath;
do {
const resp = await this.fetchApi(endpoint);
if (!resp.ok) break;
const data = await resp.json();
for (const c of data.value || []) {
allContacts.push(this.mapFromOutlook(c));
}
nextLink = data["@odata.nextLink"] || null;
if (nextLink) {
// Extract relative path from full URL
const restUrl = this.getRestUrl();
endpoint = nextLink.replace(restUrl, "");
}
} while (nextLink);
return allContacts;
}
async createContact(
contact: UnifiedContact,
folderId: string
): Promise<UnifiedContact | null> {
const outlookContact = this.mapToOutlook(contact);
const endpoint =
folderId === "default"
? "/me/contacts"
: `/me/contactfolders/${folderId}/contacts`;
const resp = await this.fetchApi(endpoint, {
method: "POST",
body: JSON.stringify(outlookContact),
});
if (!resp.ok) return null;
const created = await resp.json();
return this.mapFromOutlook(created);
}
async updateContact(
outlookId: string,
contact: UnifiedContact
): Promise<boolean> {
const outlookContact = this.mapToOutlook(contact);
const resp = await this.fetchApi(`/me/contacts/${outlookId}`, {
method: "PATCH",
body: JSON.stringify(outlookContact),
});
return resp.ok;
}
async deleteContact(outlookId: string): Promise<boolean> {
const resp = await this.fetchApi(`/me/contacts/${outlookId}`, {
method: "DELETE",
});
return resp.ok;
}
private mapFromOutlook(c: Record<string, unknown>): UnifiedContact {
const contact = emptyContact();
contact.outlookId = (c.Id as string) || "";
contact.firstName = (c.GivenName as string) || "";
contact.lastName = (c.Surname as string) || "";
contact.company = (c.CompanyName as string) || "";
contact.jobTitle = (c.JobTitle as string) || "";
contact.salutation = (c.Title as string) || "";
const emails = (c.EmailAddresses as Array<{ Address: string }>) || [];
contact.email = emails[0]?.Address || "";
contact.emailSecondary = emails[1]?.Address || "";
const phones = (c.Phones as Array<{ Type: string; Number: string }>) || [];
for (const p of phones) {
switch (p.Type) {
case "Business":
contact.phoneWork = p.Number || "";
break;
case "Mobile":
contact.phoneMobile = p.Number || "";
break;
case "Home":
contact.phoneHome = p.Number || "";
break;
case "BusinessFax":
contact.fax = p.Number || "";
break;
}
}
const addr = (c.BusinessAddress || c.HomeAddress || {}) as Record<string, string>;
contact.street = addr.Street || "";
contact.city = addr.City || "";
contact.postalCode = addr.PostalCode || "";
contact.state = addr.State || "";
contact.country = addr.CountryOrRegion || "";
const websites = (c.Websites as Array<{ Address: string }>) || [];
contact.website = websites[0]?.Address || "";
contact.notes = (c.PersonalNotes as string) || "";
contact.birthday = (c.Birthday as string) || "";
contact.lastModified = (c.LastModifiedDateTime as string) || "";
return contact;
}
private mapToOutlook(contact: UnifiedContact): Record<string, unknown> {
const c: Record<string, unknown> = {
GivenName: contact.firstName,
Surname: contact.lastName,
CompanyName: contact.company,
JobTitle: contact.jobTitle,
Title: contact.salutation,
PersonalNotes: contact.notes,
};
const emails = [];
if (contact.email) {
emails.push({ Address: contact.email, Name: contact.email });
}
if (contact.emailSecondary) {
emails.push({ Address: contact.emailSecondary, Name: contact.emailSecondary });
}
if (emails.length > 0) c.EmailAddresses = emails;
const phones = [];
if (contact.phoneWork) phones.push({ Type: "Business", Number: contact.phoneWork });
if (contact.phoneMobile) phones.push({ Type: "Mobile", Number: contact.phoneMobile });
if (contact.phoneHome) phones.push({ Type: "Home", Number: contact.phoneHome });
if (contact.fax) phones.push({ Type: "BusinessFax", Number: contact.fax });
if (phones.length > 0) c.Phones = phones;
if (contact.street || contact.city || contact.postalCode) {
c.BusinessAddress = {
Street: contact.street,
City: contact.city,
PostalCode: contact.postalCode,
State: contact.state,
CountryOrRegion: contact.country,
};
}
if (contact.website) {
c.Websites = [{ Type: "Work", Address: contact.website, Name: contact.website }];
}
if (contact.birthday) {
c.Birthday = contact.birthday;
}
return c;
}
}
-88
View File
@@ -1,88 +0,0 @@
import { SyncProfile, SyncMapping } from "../models/types";
const PROFILES_KEY = "starface-sync-profiles";
const MAPPINGS_KEY_PREFIX = "starface-sync-mappings-";
export class ProfileManager {
getProfiles(): SyncProfile[] {
try {
const data = localStorage.getItem(PROFILES_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
saveProfiles(profiles: SyncProfile[]): void {
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
}
getProfile(id: string): SyncProfile | null {
return this.getProfiles().find((p) => p.id === id) || null;
}
addProfile(profile: SyncProfile): void {
const profiles = this.getProfiles();
profiles.push(profile);
this.saveProfiles(profiles);
}
updateProfile(profile: SyncProfile): void {
const profiles = this.getProfiles();
const index = profiles.findIndex((p) => p.id === profile.id);
if (index >= 0) {
profiles[index] = profile;
this.saveProfiles(profiles);
}
}
deleteProfile(id: string): void {
const profiles = this.getProfiles().filter((p) => p.id !== id);
this.saveProfiles(profiles);
localStorage.removeItem(MAPPINGS_KEY_PREFIX + id);
}
updateLastSync(profileId: string): void {
const profiles = this.getProfiles();
const profile = profiles.find((p) => p.id === profileId);
if (profile) {
profile.lastSync = new Date().toISOString();
this.saveProfiles(profiles);
}
}
getSyncMappings(profileId: string): SyncMapping[] {
try {
const data = localStorage.getItem(MAPPINGS_KEY_PREFIX + profileId);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
saveSyncMappings(profileId: string, mappings: SyncMapping[]): void {
localStorage.setItem(
MAPPINGS_KEY_PREFIX + profileId,
JSON.stringify(mappings)
);
}
addSyncMapping(mapping: SyncMapping): void {
const mappings = this.getSyncMappings(mapping.profileId);
const existing = mappings.findIndex(
(m) =>
m.outlookId === mapping.outlookId ||
m.starfaceId === mapping.starfaceId
);
if (existing >= 0) {
mappings[existing] = mapping;
} else {
mappings.push(mapping);
}
this.saveSyncMappings(mapping.profileId, mappings);
}
generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
}
}
-358
View File
@@ -1,358 +0,0 @@
import {
StarfaceConnection,
StarfaceAddressBook,
UnifiedContact,
emptyContact,
} from "../models/types";
interface LoginResponse {
loginType: string;
nonce: string;
secret: string | null;
}
interface StarfaceContactAttribute {
displayKey: string;
name: string;
value: string;
i18nDisplayName?: string;
additionalValues?: Record<string, string>;
}
interface StarfaceContactBlock {
name: string;
resourceKey: string;
attributes: StarfaceContactAttribute[];
}
interface StarfaceContact {
id: string;
blocks: StarfaceContactBlock[];
}
interface StarfaceTag {
id: string;
name: string;
}
async function sha512(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-512", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export class StarfaceApiClient {
private baseUrl: string;
private token: string | null = null;
private connection: StarfaceConnection;
constructor(connection: StarfaceConnection) {
this.connection = connection;
const protocol = connection.useSsl ? "https" : "http";
const portPart =
(connection.useSsl && connection.port === 443) ||
(!connection.useSsl && connection.port === 80)
? ""
: `:${connection.port}`;
this.baseUrl = `${protocol}://${connection.host}${portPart}/rest`;
}
private headers(withAuth = true): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
"X-Version": "2",
};
if (withAuth && this.token) {
h["authToken"] = this.token;
}
return h;
}
async login(): Promise<boolean> {
try {
// Step 1: get nonce
const nonceResp = await fetch(`${this.baseUrl}/login`, {
method: "GET",
headers: this.headers(false),
});
if (!nonceResp.ok) throw new Error(`Login GET failed: ${nonceResp.status}`);
const loginData: LoginResponse = await nonceResp.json();
// Step 2: calculate secret
const { loginId, password } = this.connection;
const passwordHash = await sha512(password);
const combined = loginId + loginData.nonce + passwordHash;
const combinedHash = await sha512(combined);
const secret = `${loginId}:${combinedHash}`;
// Step 3: post login
const loginResp = await fetch(`${this.baseUrl}/login`, {
method: "POST",
headers: this.headers(false),
body: JSON.stringify({
loginType: loginData.loginType,
nonce: loginData.nonce,
secret,
}),
});
if (!loginResp.ok) throw new Error(`Login POST failed: ${loginResp.status}`);
const tokenData = await loginResp.json();
this.token = tokenData.token;
return true;
} catch (err) {
console.error("Starface login failed:", err);
return false;
}
}
async logout(): Promise<void> {
if (!this.token) return;
try {
await fetch(`${this.baseUrl}/login`, {
method: "DELETE",
headers: this.headers(),
});
} finally {
this.token = null;
}
}
async getCurrentUserId(): Promise<string | null> {
try {
const resp = await fetch(`${this.baseUrl}/users/me`, {
headers: this.headers(),
});
if (!resp.ok) return null;
const user = await resp.json();
return user.id || null;
} catch {
return null;
}
}
async getTags(): Promise<StarfaceTag[]> {
try {
const resp = await fetch(`${this.baseUrl}/contacts/tags`, {
headers: this.headers(),
});
if (!resp.ok) return [];
return await resp.json();
} catch {
return [];
}
}
async getAvailableAddressBooks(): Promise<StarfaceAddressBook[]> {
const books: StarfaceAddressBook[] = [];
// Central address book
books.push({
type: "central",
name: "Zentrales Adressbuch",
});
// Current user's private address book
const userId = await this.getCurrentUserId();
if (userId) {
books.push({
type: "user",
userId,
name: "Persönliches Adressbuch",
});
}
// Tags as virtual address books
const tags = await this.getTags();
for (const tag of tags) {
books.push({
type: "tag",
tagId: tag.id,
name: `Tag: ${tag.name}`,
});
}
return books;
}
async getContacts(
addressBook: StarfaceAddressBook,
page = 0,
pageSize = 200
): Promise<UnifiedContact[]> {
const params = new URLSearchParams({
page: page.toString(),
pagesize: pageSize.toString(),
});
if (addressBook.type === "user" && addressBook.userId) {
params.set("userId", addressBook.userId);
}
if (addressBook.type === "tag" && addressBook.tagId) {
params.set("tags", addressBook.tagId);
}
const allContacts: UnifiedContact[] = [];
let currentPage = page;
let hasMore = true;
while (hasMore) {
params.set("page", currentPage.toString());
const resp = await fetch(`${this.baseUrl}/contacts?${params}`, {
headers: this.headers(),
});
if (!resp.ok) break;
const contacts: StarfaceContact[] = await resp.json();
if (contacts.length === 0) {
hasMore = false;
break;
}
for (const sc of contacts) {
allContacts.push(this.mapFromStarface(sc));
}
if (contacts.length < pageSize) {
hasMore = false;
} else {
currentPage++;
}
}
return allContacts;
}
async createContact(
contact: UnifiedContact,
addressBook: StarfaceAddressBook
): Promise<UnifiedContact | null> {
const starfaceContact = this.mapToStarface(contact, addressBook);
const params = new URLSearchParams();
if (addressBook.type === "user" && addressBook.userId) {
params.set("userId", addressBook.userId);
}
const url = `${this.baseUrl}/contacts${params.toString() ? "?" + params : ""}`;
const resp = await fetch(url, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(starfaceContact),
});
if (!resp.ok) return null;
const created: StarfaceContact = await resp.json();
return this.mapFromStarface(created);
}
async updateContact(
contactId: string,
contact: UnifiedContact,
addressBook: StarfaceAddressBook
): Promise<boolean> {
const starfaceContact = this.mapToStarface(contact, addressBook);
starfaceContact.id = contactId;
const params = new URLSearchParams();
if (addressBook.type === "user" && addressBook.userId) {
params.set("userId", addressBook.userId);
}
const url = `${this.baseUrl}/contacts/${contactId}${params.toString() ? "?" + params : ""}`;
const resp = await fetch(url, {
method: "PUT",
headers: this.headers(),
body: JSON.stringify(starfaceContact),
});
return resp.ok;
}
async deleteContact(contactId: string): Promise<boolean> {
const resp = await fetch(`${this.baseUrl}/contacts/${contactId}`, {
method: "DELETE",
headers: this.headers(),
});
return resp.ok;
}
private mapFromStarface(sc: StarfaceContact): UnifiedContact {
const contact = emptyContact();
contact.starfaceId = sc.id;
const attrs: Record<string, string> = {};
for (const block of sc.blocks || []) {
for (const attr of block.attributes || []) {
if (attr.value) {
attrs[attr.displayKey] = attr.value;
}
}
}
contact.firstName = attrs["NAME"] || "";
contact.lastName = attrs["SURNAME"] || "";
contact.company = attrs["COMPANY"] || "";
contact.jobTitle = attrs["JOB_TITLE"] || "";
contact.email = attrs["EMAIL"] || "";
contact.phoneWork = attrs["OFFICE_PHONE_NUMBER"] || "";
contact.phoneMobile = attrs["MOBILE_PHONE_NUMBER"] || "";
contact.phoneHome = attrs["PRIVATE_PHONE_NUMBER"] || "";
contact.fax = attrs["FAX_NUMBER"] || "";
contact.street = attrs["STREET"] || "";
contact.city = attrs["CITY"] || "";
contact.postalCode = attrs["POSTAL_CODE"] || "";
contact.state = attrs["STATE"] || "";
contact.country = attrs["COUNTRY"] || "";
contact.website = attrs["URL"] || "";
contact.notes = attrs["NOTE"] || "";
contact.salutation = attrs["SALUTATION"] || "";
contact.title = attrs["TITLE"] || "";
contact.birthday = attrs["BIRTHDAY"] || "";
return contact;
}
private mapToStarface(
contact: UnifiedContact,
addressBook: StarfaceAddressBook
): StarfaceContact {
const attributes: StarfaceContactAttribute[] = [];
const addAttr = (displayKey: string, name: string, value: string) => {
if (value) {
attributes.push({ displayKey, name: name.toLowerCase(), value });
}
};
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);
const sc: StarfaceContact = {
id: contact.starfaceId || "",
blocks: [
{
name: "contact",
resourceKey: "contact",
attributes,
},
],
};
return sc;
}
}
-296
View File
@@ -1,296 +0,0 @@
import {
SyncProfile,
SyncMapping,
SyncResult,
UnifiedContact,
} from "../models/types";
import { StarfaceApiClient } from "./starface-api";
import { OutlookContactsService } from "./outlook-contacts";
import { ProfileManager } from "./profile-manager";
/** Simple hash of contact fields for change detection */
function hashContact(c: UnifiedContact): string {
const fields = [
c.firstName, c.lastName, c.company, c.jobTitle,
c.email, c.emailSecondary,
c.phoneWork, c.phoneMobile, c.phoneHome, c.fax,
c.street, c.city, c.postalCode, c.state, c.country,
c.website, c.notes, c.salutation, c.title, c.birthday,
];
return fields.join("|");
}
/** Match contacts by name + email (since IDs differ between systems) */
function findMatch(
contact: UnifiedContact,
candidates: UnifiedContact[]
): UnifiedContact | null {
// First try exact email match
if (contact.email) {
const byEmail = candidates.find(
(c) => c.email && c.email.toLowerCase() === contact.email.toLowerCase()
);
if (byEmail) return byEmail;
}
// Then try name match
if (contact.firstName || contact.lastName) {
const byName = candidates.find(
(c) =>
c.firstName.toLowerCase() === contact.firstName.toLowerCase() &&
c.lastName.toLowerCase() === contact.lastName.toLowerCase() &&
(c.firstName !== "" || c.lastName !== "")
);
if (byName) return byName;
}
return null;
}
export class SyncEngine {
private outlookService: OutlookContactsService;
private profileManager: ProfileManager;
constructor() {
this.outlookService = new OutlookContactsService();
this.profileManager = new ProfileManager();
}
async syncProfile(
profile: SyncProfile,
onProgress?: (msg: string) => void
): Promise<SyncResult> {
const result: SyncResult = {
profileId: profile.id,
profileName: profile.name,
timestamp: new Date().toISOString(),
created: 0,
updated: 0,
deleted: 0,
errors: [],
direction: profile.syncDirection,
};
const log = (msg: string) => {
onProgress?.(msg);
};
try {
// Connect to Starface
log("Verbinde mit Starface...");
const starface = new StarfaceApiClient(profile.starfaceConnection);
const loginOk = await starface.login();
if (!loginOk) {
result.errors.push("Starface-Login fehlgeschlagen");
return result;
}
// Load existing sync mappings
const mappings = this.profileManager.getSyncMappings(profile.id);
// Get contacts from both systems
log("Lade Outlook-Kontakte...");
const outlookContacts = await this.outlookService.getContacts(
profile.outlookFolderId
);
log(`${outlookContacts.length} Outlook-Kontakte geladen`);
log("Lade Starface-Kontakte...");
const starfaceContacts = await starface.getContacts(
profile.starfaceAddressBook
);
log(`${starfaceContacts.length} Starface-Kontakte geladen`);
// Build lookup maps from existing mappings
const mappingByOutlook = new Map<string, SyncMapping>();
const mappingByStarface = new Map<string, SyncMapping>();
for (const m of mappings) {
mappingByOutlook.set(m.outlookId, m);
mappingByStarface.set(m.starfaceId, m);
}
// Sync Outlook -> Starface
if (
profile.syncDirection === "both" ||
profile.syncDirection === "outlook-to-starface"
) {
log("Synchronisiere Outlook → Starface...");
for (const oc of outlookContacts) {
try {
const existingMapping = oc.outlookId
? mappingByOutlook.get(oc.outlookId)
: null;
if (existingMapping) {
// Known contact - check if changed
const currentHash = hashContact(oc);
if (currentHash !== existingMapping.lastSyncHash) {
const ok = await starface.updateContact(
existingMapping.starfaceId,
oc,
profile.starfaceAddressBook
);
if (ok) {
existingMapping.lastSyncHash = currentHash;
result.updated++;
}
}
} else {
// New contact - check if exists in Starface by name/email
const match = findMatch(oc, starfaceContacts);
if (match && match.starfaceId) {
// Link existing
const ok = await starface.updateContact(
match.starfaceId,
oc,
profile.starfaceAddressBook
);
if (ok) {
this.profileManager.addSyncMapping({
profileId: profile.id,
outlookId: oc.outlookId || "",
starfaceId: match.starfaceId,
lastSyncHash: hashContact(oc),
});
result.updated++;
}
} else {
// Create new in Starface
const created = await starface.createContact(
oc,
profile.starfaceAddressBook
);
if (created?.starfaceId) {
this.profileManager.addSyncMapping({
profileId: profile.id,
outlookId: oc.outlookId || "",
starfaceId: created.starfaceId,
lastSyncHash: hashContact(oc),
});
result.created++;
}
}
}
} catch (err) {
result.errors.push(
`Fehler bei ${oc.firstName} ${oc.lastName}: ${err}`
);
}
}
}
// Sync Starface -> Outlook
if (
profile.syncDirection === "both" ||
profile.syncDirection === "starface-to-outlook"
) {
log("Synchronisiere Starface → Outlook...");
for (const sc of starfaceContacts) {
try {
const existingMapping = sc.starfaceId
? mappingByStarface.get(sc.starfaceId)
: null;
if (existingMapping) {
// Known contact - check if changed on Starface side
const currentHash = hashContact(sc);
if (currentHash !== existingMapping.lastSyncHash) {
const ok = await this.outlookService.updateContact(
existingMapping.outlookId,
sc
);
if (ok) {
existingMapping.lastSyncHash = currentHash;
result.updated++;
}
}
} else {
// New in Starface - check if exists in Outlook
const match = findMatch(sc, outlookContacts);
if (match && match.outlookId) {
// Link existing
const ok = await this.outlookService.updateContact(
match.outlookId,
sc
);
if (ok) {
this.profileManager.addSyncMapping({
profileId: profile.id,
outlookId: match.outlookId,
starfaceId: sc.starfaceId || "",
lastSyncHash: hashContact(sc),
});
result.updated++;
}
} else {
// Create new in Outlook
const created = await this.outlookService.createContact(
sc,
profile.outlookFolderId
);
if (created?.outlookId) {
this.profileManager.addSyncMapping({
profileId: profile.id,
outlookId: created.outlookId,
starfaceId: sc.starfaceId || "",
lastSyncHash: hashContact(sc),
});
result.created++;
}
}
}
} catch (err) {
result.errors.push(
`Fehler bei ${sc.firstName} ${sc.lastName}: ${err}`
);
}
}
}
// Update last sync time
this.profileManager.updateLastSync(profile.id);
// Save updated mappings
this.profileManager.saveSyncMappings(profile.id, mappings);
// Logout
await starface.logout();
log("Synchronisation abgeschlossen!");
} catch (err) {
result.errors.push(`Allgemeiner Fehler: ${err}`);
}
return result;
}
async testStarfaceConnection(
connection: import("../models/types").StarfaceConnection
): Promise<{ success: boolean; message: string }> {
try {
const client = new StarfaceApiClient(connection);
const ok = await client.login();
if (ok) {
await client.logout();
return { success: true, message: "Verbindung erfolgreich!" };
}
return { success: false, message: "Login fehlgeschlagen" };
} catch (err) {
return { success: false, message: `Fehler: ${err}` };
}
}
async loadStarfaceAddressBooks(
connection: import("../models/types").StarfaceConnection
): Promise<import("../models/types").StarfaceAddressBook[]> {
const client = new StarfaceApiClient(connection);
const ok = await client.login();
if (!ok) return [];
const books = await client.getAvailableAddressBooks();
await client.logout();
return books;
}
async loadOutlookFolders(): Promise<import("../models/types").OutlookContactFolder[]> {
return this.outlookService.getContactFolders();
}
}
-63
View File
@@ -1,63 +0,0 @@
import React from "react";
import {
Modal,
IconButton,
Stack,
Text,
} from "@fluentui/react";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export const AboutDialog: React.FC<Props> = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onDismiss={onClose}
isBlocking={false}
containerClassName="about-modal"
>
<div className="about-dialog">
<div className="about-header">
<Text variant="xLarge">Info</Text>
<IconButton
iconProps={{ iconName: "Cancel" }}
onClick={onClose}
ariaLabel="Schließen"
className="about-close-btn"
/>
</div>
<div className="about-content">
<div className="about-logo">
<Text variant="xxLarge" className="about-product-name">
Outlook-SYNC &harr; Starface
</Text>
<Text variant="small" className="about-version">
Version 0.0.0.1
</Text>
</div>
<div className="about-divider" />
<div className="about-company">
<Text variant="large" block className="about-company-name">
HackerSoft
</Text>
<Text variant="medium" block className="about-company-sub">
Hacker-Net Telekommunikation
</Text>
</div>
<div className="about-address">
<Text variant="small" block>Stefan Hacker</Text>
<Text variant="small" block>Am Wunderburgpark 5b</Text>
<Text variant="small" block>26135 Oldenburg</Text>
</div>
</div>
</div>
</Modal>
);
};
-82
View File
@@ -1,82 +0,0 @@
import React, { useState } from "react";
import { ThemeProvider, createTheme, IconButton } from "@fluentui/react";
import { ProfileList } from "./ProfileList";
import { ProfileEditor } from "./ProfileEditor";
import { SyncView } from "./SyncView";
import { AboutDialog } from "./AboutDialog";
import { SyncProfile } from "../../models/types";
const theme = createTheme({
palette: {
themePrimary: "#0078d4",
themeDark: "#005a9e",
neutralLight: "#f3f2f1",
},
});
type View = "list" | "edit" | "sync";
export const App: React.FC = () => {
const [view, setView] = useState<View>("list");
const [editProfile, setEditProfile] = useState<SyncProfile | null>(null);
const [syncProfileId, setSyncProfileId] = useState<string | null>(null);
const [showAbout, setShowAbout] = useState(false);
const handleNewProfile = () => {
setEditProfile(null);
setView("edit");
};
const handleEditProfile = (profile: SyncProfile) => {
setEditProfile(profile);
setView("edit");
};
const handleSync = (profileId: string) => {
setSyncProfileId(profileId);
setView("sync");
};
const handleBack = () => {
setView("list");
setEditProfile(null);
setSyncProfileId(null);
};
return (
<ThemeProvider theme={theme}>
<div className="app-container">
<header className="app-header">
<h1>Starface Kontakt-Sync</h1>
<IconButton
iconProps={{ iconName: "Info" }}
title="Info"
ariaLabel="Info"
onClick={() => setShowAbout(true)}
className="header-info-btn"
/>
</header>
<AboutDialog isOpen={showAbout} onClose={() => setShowAbout(false)} />
<main className="app-main">
{view === "list" && (
<ProfileList
onNew={handleNewProfile}
onEdit={handleEditProfile}
onSync={handleSync}
/>
)}
{view === "edit" && (
<ProfileEditor
profile={editProfile}
onSave={handleBack}
onCancel={handleBack}
/>
)}
{view === "sync" && syncProfileId && (
<SyncView profileId={syncProfileId} onBack={handleBack} />
)}
</main>
</div>
</ThemeProvider>
);
};
-355
View File
@@ -1,355 +0,0 @@
import React, { useState, useEffect } from "react";
import {
PrimaryButton,
DefaultButton,
TextField,
Dropdown,
IDropdownOption,
Stack,
Text,
Toggle,
Spinner,
SpinnerSize,
MessageBar,
MessageBarType,
} from "@fluentui/react";
import {
SyncProfile,
StarfaceConnection,
StarfaceAddressBook,
OutlookContactFolder,
} from "../../models/types";
import { ProfileManager } from "../../services/profile-manager";
import { SyncEngine } from "../../services/sync-engine";
interface Props {
profile: SyncProfile | null;
onSave: () => void;
onCancel: () => void;
}
const defaultConnection: StarfaceConnection = {
host: "",
port: 443,
useSsl: true,
loginId: "",
password: "",
};
export const ProfileEditor: React.FC<Props> = ({
profile,
onSave,
onCancel,
}) => {
const pm = new ProfileManager();
const engine = new SyncEngine();
const isNew = !profile;
const [name, setName] = useState(profile?.name || "");
const [connection, setConnection] = useState<StarfaceConnection>(
profile?.starfaceConnection || { ...defaultConnection }
);
const [addressBooks, setAddressBooks] = useState<StarfaceAddressBook[]>([]);
const [selectedBook, setSelectedBook] = useState<StarfaceAddressBook | null>(
profile?.starfaceAddressBook || null
);
const [outlookFolders, setOutlookFolders] = useState<OutlookContactFolder[]>(
[]
);
const [selectedFolderId, setSelectedFolderId] = useState(
profile?.outlookFolderId || ""
);
const [syncDirection, setSyncDirection] = useState<SyncProfile["syncDirection"]>(
profile?.syncDirection || "both"
);
const [enabled, setEnabled] = useState(profile?.enabled ?? true);
const [loading, setLoading] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [error, setError] = useState("");
// Load Outlook folders on mount
useEffect(() => {
loadOutlookFolders();
}, []);
const loadOutlookFolders = async () => {
try {
const folders = await engine.loadOutlookFolders();
setOutlookFolders(folders);
if (!selectedFolderId && folders.length > 0) {
setSelectedFolderId(folders[0].id);
}
} catch (err) {
console.error("Failed to load Outlook folders:", err);
// Provide a default folder option
setOutlookFolders([{ id: "default", displayName: "Kontakte (Standard)" }]);
if (!selectedFolderId) setSelectedFolderId("default");
}
};
const handleTestConnection = async () => {
setLoading(true);
setTestResult(null);
const result = await engine.testStarfaceConnection(connection);
setTestResult(result);
if (!result.success && result.message.toLowerCase().includes("fetch")) {
result.message +=
"\n\nMögliche Ursache: Das SSL-Zertifikat der Starface ist nicht vertrauenswürdig. " +
"Bitte als Administrator ausführen:\n" +
`import-cert.ps1 -StarfaceHost ${connection.host} -Port ${connection.port}`;
}
setLoading(false);
};
const handleLoadAddressBooks = async () => {
setLoading(true);
setError("");
try {
const books = await engine.loadStarfaceAddressBooks(connection);
setAddressBooks(books);
if (books.length > 0 && !selectedBook) {
setSelectedBook(books[0]);
}
if (books.length === 0) {
setError("Keine Adressbücher gefunden");
}
} catch (err) {
setError(`Fehler: ${err}`);
}
setLoading(false);
};
const handleSave = () => {
if (!name.trim()) {
setError("Bitte einen Profilnamen eingeben");
return;
}
if (!connection.host.trim()) {
setError("Bitte Starface-Host eingeben");
return;
}
if (!selectedBook) {
setError("Bitte ein Starface-Adressbuch auswählen");
return;
}
if (!selectedFolderId) {
setError("Bitte einen Outlook-Ordner auswählen");
return;
}
const selectedFolder = outlookFolders.find(
(f) => f.id === selectedFolderId
);
const newProfile: SyncProfile = {
id: profile?.id || pm.generateId(),
name: name.trim(),
starfaceConnection: connection,
starfaceAddressBook: selectedBook,
outlookFolderId: selectedFolderId,
outlookFolderName:
selectedFolder?.displayName || "Kontakte",
syncDirection,
lastSync: profile?.lastSync,
enabled,
};
if (isNew) {
pm.addProfile(newProfile);
} else {
pm.updateProfile(newProfile);
}
onSave();
};
const directionOptions: IDropdownOption[] = [
{ key: "both", text: "Bidirektional (↔)" },
{ key: "outlook-to-starface", text: "Outlook → Starface" },
{ key: "starface-to-outlook", text: "Starface → Outlook" },
];
const bookOptions: IDropdownOption[] = addressBooks.map((b, i) => ({
key: i.toString(),
text: b.name,
data: b,
}));
const folderOptions: IDropdownOption[] = outlookFolders.map((f) => ({
key: f.id,
text: f.displayName,
}));
return (
<div className="profile-editor">
<Stack tokens={{ childrenGap: 16 }}>
<Stack horizontal horizontalAlign="space-between" verticalAlign="center">
<Text variant="xLarge">
{isNew ? "Neues Profil" : "Profil bearbeiten"}
</Text>
<DefaultButton text="Zurück" onClick={onCancel} />
</Stack>
{error && (
<MessageBar
messageBarType={MessageBarType.error}
onDismiss={() => setError("")}
>
{error}
</MessageBar>
)}
<TextField
label="Profilname"
value={name}
onChange={(_, v) => setName(v || "")}
placeholder="z.B. Firmenkontakte Hauptanlage"
required
/>
<Text variant="large" className="section-title">
Starface-Verbindung
</Text>
<Stack horizontal tokens={{ childrenGap: 8 }}>
<Stack.Item grow>
<TextField
label="Host / IP-Adresse"
value={connection.host}
onChange={(_, v) =>
setConnection({ ...connection, host: v || "" })
}
placeholder="z.B. pbx.firma.de oder 192.168.1.100"
required
/>
</Stack.Item>
<TextField
label="Port"
value={connection.port.toString()}
onChange={(_, v) =>
setConnection({ ...connection, port: parseInt(v || "443") || 443 })
}
styles={{ root: { width: 80 } }}
/>
</Stack>
<Toggle
label="HTTPS verwenden"
checked={connection.useSsl}
onChange={(_, checked) =>
setConnection({
...connection,
useSsl: checked ?? true,
port: checked ? 443 : 80,
})
}
/>
<TextField
label="Login-ID"
value={connection.loginId}
onChange={(_, v) =>
setConnection({ ...connection, loginId: v || "" })
}
required
/>
<TextField
label="Kennwort"
type="password"
value={connection.password}
onChange={(_, v) =>
setConnection({ ...connection, password: v || "" })
}
canRevealPassword
required
/>
<Stack horizontal tokens={{ childrenGap: 8 }}>
<DefaultButton
text="Verbindung testen"
onClick={handleTestConnection}
disabled={loading || !connection.host || !connection.loginId}
/>
<PrimaryButton
text="Adressbücher laden"
onClick={handleLoadAddressBooks}
disabled={loading || !connection.host || !connection.loginId}
/>
{loading && <Spinner size={SpinnerSize.small} />}
</Stack>
{testResult && (
<MessageBar
messageBarType={
testResult.success
? MessageBarType.success
: MessageBarType.error
}
>
{testResult.message.split("\n").map((line, i) => (
<span key={i}>
{i > 0 && <br />}
{line}
</span>
))}
</MessageBar>
)}
{addressBooks.length > 0 && (
<Dropdown
label="Starface-Adressbuch"
options={bookOptions}
selectedKey={
selectedBook
? addressBooks.indexOf(selectedBook).toString()
: undefined
}
onChange={(_, option) => {
if (option?.data) setSelectedBook(option.data);
}}
required
/>
)}
<Text variant="large" className="section-title">
Outlook-Einstellungen
</Text>
<Dropdown
label="Outlook Kontakte-Ordner"
options={folderOptions}
selectedKey={selectedFolderId}
onChange={(_, option) => {
if (option) setSelectedFolderId(option.key as string);
}}
required
/>
<Dropdown
label="Synchronisationsrichtung"
options={directionOptions}
selectedKey={syncDirection}
onChange={(_, option) => {
if (option) setSyncDirection(option.key as SyncProfile["syncDirection"]);
}}
/>
<Toggle
label="Profil aktiviert"
checked={enabled}
onChange={(_, checked) => setEnabled(checked ?? true)}
/>
<Stack horizontal tokens={{ childrenGap: 8 }}>
<PrimaryButton text="Speichern" onClick={handleSave} />
<DefaultButton text="Abbrechen" onClick={onCancel} />
</Stack>
</Stack>
</div>
);
};
-138
View File
@@ -1,138 +0,0 @@
import React, { useState, useEffect } from "react";
import {
PrimaryButton,
DefaultButton,
IconButton,
Stack,
Text,
MessageBar,
MessageBarType,
Toggle,
} from "@fluentui/react";
import { SyncProfile } from "../../models/types";
import { ProfileManager } from "../../services/profile-manager";
interface Props {
onNew: () => void;
onEdit: (profile: SyncProfile) => void;
onSync: (profileId: string) => void;
}
export const ProfileList: React.FC<Props> = ({ onNew, onEdit, onSync }) => {
const [profiles, setProfiles] = useState<SyncProfile[]>([]);
const pm = new ProfileManager();
useEffect(() => {
setProfiles(pm.getProfiles());
}, []);
const handleDelete = (id: string) => {
if (confirm("Profil wirklich löschen?")) {
pm.deleteProfile(id);
setProfiles(pm.getProfiles());
}
};
const handleToggle = (id: string, enabled: boolean) => {
const profile = pm.getProfile(id);
if (profile) {
profile.enabled = enabled;
pm.updateProfile(profile);
setProfiles(pm.getProfiles());
}
};
const formatDate = (iso?: string) => {
if (!iso) return "Noch nie";
return new Date(iso).toLocaleString("de-DE");
};
return (
<div className="profile-list">
<Stack tokens={{ childrenGap: 12 }}>
<Stack horizontal horizontalAlign="space-between" verticalAlign="center">
<Text variant="xLarge">Sync-Profile</Text>
<PrimaryButton
text="Neues Profil"
iconProps={{ iconName: "Add" }}
onClick={onNew}
/>
</Stack>
{profiles.length === 0 && (
<MessageBar messageBarType={MessageBarType.info}>
Noch keine Sync-Profile angelegt. Erstellen Sie ein neues Profil, um
Kontakte zu synchronisieren.
</MessageBar>
)}
{profiles.map((profile) => (
<div key={profile.id} className="profile-card">
<Stack tokens={{ childrenGap: 8 }}>
<Stack
horizontal
horizontalAlign="space-between"
verticalAlign="center"
>
<Text variant="large" className="profile-name">
{profile.name}
</Text>
<Toggle
checked={profile.enabled}
onChange={(_, checked) =>
handleToggle(profile.id, checked ?? false)
}
onText="Aktiv"
offText="Inaktiv"
/>
</Stack>
<div className="profile-details">
<Text variant="small" block>
<strong>Starface:</strong>{" "}
{profile.starfaceConnection.host} &rarr;{" "}
{profile.starfaceAddressBook.name}
</Text>
<Text variant="small" block>
<strong>Outlook:</strong> {profile.outlookFolderName}
</Text>
<Text variant="small" block>
<strong>Richtung:</strong>{" "}
{profile.syncDirection === "both"
? "Bidirektional"
: profile.syncDirection === "outlook-to-starface"
? "Outlook → Starface"
: "Starface → Outlook"}
</Text>
<Text variant="small" block>
<strong>Letzte Sync:</strong>{" "}
{formatDate(profile.lastSync)}
</Text>
</div>
<Stack horizontal tokens={{ childrenGap: 8 }}>
<PrimaryButton
text="Jetzt synchronisieren"
iconProps={{ iconName: "Sync" }}
onClick={() => onSync(profile.id)}
disabled={!profile.enabled}
/>
<DefaultButton
text="Bearbeiten"
iconProps={{ iconName: "Edit" }}
onClick={() => onEdit(profile)}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
title="Löschen"
onClick={() => handleDelete(profile.id)}
className="delete-btn"
/>
</Stack>
</Stack>
</div>
))}
</Stack>
</div>
);
};
-176
View File
@@ -1,176 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import {
PrimaryButton,
DefaultButton,
Stack,
Text,
ProgressIndicator,
MessageBar,
MessageBarType,
} from "@fluentui/react";
import { SyncResult } from "../../models/types";
import { ProfileManager } from "../../services/profile-manager";
import { SyncEngine } from "../../services/sync-engine";
interface Props {
profileId: string;
onBack: () => void;
}
export const SyncView: React.FC<Props> = ({ profileId, onBack }) => {
const pm = new ProfileManager();
const profile = pm.getProfile(profileId);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<string[]>([]);
const [result, setResult] = useState<SyncResult | null>(null);
const logRef = useRef<HTMLDivElement>(null);
const addProgress = (msg: string) => {
setProgress((prev) => [...prev, `[${new Date().toLocaleTimeString("de-DE")}] ${msg}`]);
};
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [progress]);
const handleSync = async () => {
if (!profile) return;
setRunning(true);
setProgress([]);
setResult(null);
addProgress("Synchronisation gestartet...");
const engine = new SyncEngine();
const syncResult = await engine.syncProfile(profile, (msg) => {
addProgress(msg);
});
setResult(syncResult);
setRunning(false);
addProgress("Fertig.");
};
if (!profile) {
return (
<MessageBar messageBarType={MessageBarType.error}>
Profil nicht gefunden.
<DefaultButton text="Zurück" onClick={onBack} />
</MessageBar>
);
}
return (
<div className="sync-view">
<Stack tokens={{ childrenGap: 12 }}>
<Stack horizontal horizontalAlign="space-between" verticalAlign="center">
<Text variant="xLarge">Synchronisation</Text>
<DefaultButton text="Zurück" onClick={onBack} disabled={running} />
</Stack>
<div className="sync-info">
<Text variant="medium" block>
<strong>Profil:</strong> {profile.name}
</Text>
<Text variant="small" block>
{profile.starfaceConnection.host} ({profile.starfaceAddressBook.name})
{" ↔ "}
{profile.outlookFolderName}
</Text>
</div>
{!running && !result && (
<PrimaryButton
text="Synchronisation starten"
iconProps={{ iconName: "Sync" }}
onClick={handleSync}
/>
)}
{running && (
<ProgressIndicator
label="Synchronisiere..."
description="Bitte warten..."
/>
)}
{progress.length > 0 && (
<div className="sync-log" ref={logRef}>
{progress.map((msg, i) => (
<Text key={i} variant="small" block className="log-line">
{msg}
</Text>
))}
</div>
)}
{result && (
<div className="sync-result">
{result.errors.length === 0 ? (
<MessageBar messageBarType={MessageBarType.success}>
Synchronisation erfolgreich abgeschlossen!
</MessageBar>
) : (
<MessageBar messageBarType={MessageBarType.warning}>
Synchronisation mit {result.errors.length} Fehler(n)
abgeschlossen.
</MessageBar>
)}
<div className="result-stats">
<Stack horizontal tokens={{ childrenGap: 24 }}>
<div className="stat">
<Text variant="xxLarge" className="stat-number">
{result.created}
</Text>
<Text variant="small">Erstellt</Text>
</div>
<div className="stat">
<Text variant="xxLarge" className="stat-number">
{result.updated}
</Text>
<Text variant="small">Aktualisiert</Text>
</div>
<div className="stat">
<Text variant="xxLarge" className="stat-number">
{result.errors.length}
</Text>
<Text variant="small">Fehler</Text>
</div>
</Stack>
</div>
{result.errors.length > 0 && (
<div className="error-list">
<Text variant="medium" block>
<strong>Fehler:</strong>
</Text>
{result.errors.map((err, i) => (
<MessageBar
key={i}
messageBarType={MessageBarType.error}
className="error-item"
>
{err}
</MessageBar>
))}
</div>
)}
<Stack horizontal tokens={{ childrenGap: 8 }} className="result-actions">
<PrimaryButton
text="Erneut synchronisieren"
iconProps={{ iconName: "Sync" }}
onClick={handleSync}
/>
<DefaultButton text="Zurück zur Übersicht" onClick={onBack} />
</Stack>
</div>
)}
</Stack>
</div>
);
};
-16
View File
@@ -1,16 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./components/App";
import "./styles/taskpane.css";
/* global Office */
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
const container = document.getElementById("root");
if (container) {
const root = createRoot(container);
root.render(<App />);
}
}
});
-259
View File
@@ -1,259 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
color: #323130;
background: #faf9f8;
}
.app-container {
max-width: 600px;
margin: 0 auto;
padding: 0;
}
.app-header {
background: linear-gradient(135deg, #0078d4, #005a9e);
color: white;
padding: 16px 20px;
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-header h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-info-btn {
color: white !important;
background: transparent !important;
}
.header-info-btn:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
/* About Dialog */
.about-modal {
max-width: 360px;
border-radius: 8px;
}
.about-dialog {
padding: 0;
}
.about-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px;
}
.about-close-btn {
color: #605e5c !important;
}
.about-content {
padding: 0 20px 24px;
text-align: center;
}
.about-logo {
padding: 16px 0;
}
.about-product-name {
font-weight: 700 !important;
color: #0078d4 !important;
display: block;
}
.about-version {
color: #605e5c !important;
display: block;
margin-top: 4px;
}
.about-divider {
height: 1px;
background: #edebe9;
margin: 16px 0;
}
.about-company {
margin-bottom: 12px;
}
.about-company-name {
font-weight: 700 !important;
}
.about-company-sub {
color: #605e5c !important;
}
.about-address {
color: #605e5c;
line-height: 1.6;
}
.app-main {
padding: 16px 20px;
}
/* Profile List */
.profile-list {
animation: fadeIn 0.2s ease;
}
.profile-card {
background: white;
border: 1px solid #edebe9;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.15s;
}
.profile-card:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.profile-name {
font-weight: 600;
}
.profile-details {
background: #f3f2f1;
border-radius: 4px;
padding: 10px 12px;
}
.profile-details .ms-Text {
margin-bottom: 2px;
}
.delete-btn {
color: #a4262c !important;
}
.delete-btn:hover {
background: #fde7e9 !important;
}
/* Profile Editor */
.profile-editor {
animation: fadeIn 0.2s ease;
}
.section-title {
font-weight: 600;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid #edebe9;
}
/* Sync View */
.sync-view {
animation: fadeIn 0.2s ease;
}
.sync-info {
background: #f3f2f1;
border-radius: 4px;
padding: 12px;
}
.sync-log {
background: #1e1e1e;
color: #d4d4d4;
border-radius: 4px;
padding: 12px;
max-height: 250px;
overflow-y: auto;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
}
.log-line {
padding: 1px 0;
font-family: inherit !important;
color: #d4d4d4 !important;
}
.sync-result {
margin-top: 8px;
}
.result-stats {
background: white;
border: 1px solid #edebe9;
border-radius: 4px;
padding: 16px;
margin: 12px 0;
text-align: center;
}
.stat {
text-align: center;
}
.stat-number {
color: #0078d4;
font-weight: 700;
display: block;
}
.error-list {
margin: 12px 0;
}
.error-item {
margin-top: 4px;
}
.result-actions {
margin-top: 12px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c8c6c4;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a19f9d;
}
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Starface Kontakt-Sync</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"declaration": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-67
View File
@@ -1,67 +0,0 @@
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const devCerts = require("office-addin-dev-certs");
module.exports = async (env, argv) => {
const isDev = argv.mode === "development";
const httpsOptions = isDev ? await devCerts.getHttpsServerOptions() : undefined;
return {
entry: {
taskpane: "./src/taskpane/index.tsx",
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].bundle.js",
clean: true,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
alias: {
"@": path.resolve(__dirname, "src"),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|jpg|jpeg|gif|svg|ico)$/,
type: "asset/resource",
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/taskpane/taskpane.html",
filename: "taskpane.html",
chunks: ["taskpane"],
}),
new CopyWebpackPlugin({
patterns: [
{ from: "assets", to: "assets" },
{ from: "manifest.xml", to: "manifest.xml" },
],
}),
],
devServer: {
port: 3000,
https: httpsOptions || true,
headers: {
"Access-Control-Allow-Origin": "*",
},
static: {
directory: path.resolve(__dirname, "dist"),
},
},
devtool: isDev ? "source-map" : false,
};
};