Compare commits

..

22 Commits

Author SHA1 Message Date
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
56 changed files with 2853 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.5"
#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,37 @@
using System;
using System.IO;
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
{
public class UserSettings
{
public bool StartMinimized { 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 { }
}
}
}
+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,341 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using StarfaceOutlookSync.Models;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace StarfaceOutlookSync.Services
{
public class OutlookContactsService : IDisposable
{
private Outlook.Application _outlookApp;
private bool _weStartedOutlook;
// Marshal.GetActiveObject existiert nicht in .NET 8, daher P/Invoke
[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 clsid = Type.GetTypeFromProgID(progId, true).GUID;
GetActiveObject(clsid, IntPtr.Zero, out var obj);
return obj;
}
private Outlook.Application GetOutlookApp()
{
if (_outlookApp != null) return _outlookApp;
try
{
// Versuche laufende Outlook-Instanz zu finden
_outlookApp = (Outlook.Application)GetActiveComObject("Outlook.Application");
_weStartedOutlook = false;
}
catch
{
// Outlook starten falls nicht laufend
_outlookApp = new Outlook.Application();
_weStartedOutlook = true;
}
return _outlookApp;
}
public List<string> GetContactFolderPaths()
{
var folders = new List<string>();
try
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Alle Stores durchgehen (jedes Konto, jede PST-Datei etc.)
foreach (Outlook.Store store in ns.Stores)
{
try
{
var rootFolder = store.GetRootFolder();
FindContactFoldersRecursive(rootFolder, folders);
Marshal.ReleaseComObject(rootFolder);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning store '{store.DisplayName}': {ex.Message}");
}
}
// Falls nichts gefunden, Standard-Kontaktordner als Fallback
if (folders.Count == 0)
{
try
{
var defaultFolder = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
folders.Add(defaultFolder.FolderPath);
Marshal.ReleaseComObject(defaultFolder);
}
catch { }
}
Marshal.ReleaseComObject(ns);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error getting folders: {ex.Message}");
}
return folders;
}
private void FindContactFoldersRecursive(Outlook.MAPIFolder folder, List<string> paths)
{
try
{
// Kontaktordner erkennen: DefaultItemType ODER Ordnername enthaelt "Kontakt"/"Contact"
if (folder.DefaultItemType == Outlook.OlItemType.olContactItem)
{
if (!paths.Contains(folder.FolderPath))
paths.Add(folder.FolderPath);
}
// Alle Unterordner durchsuchen
foreach (Outlook.MAPIFolder sub in folder.Folders)
{
try
{
FindContactFoldersRecursive(sub, paths);
}
catch { }
finally
{
Marshal.ReleaseComObject(sub);
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning folder '{folder.Name}': {ex.Message}");
}
}
private Outlook.MAPIFolder GetFolderByPath(string folderPath)
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Standard-Kontaktordner als Fallback
if (string.IsNullOrEmpty(folderPath))
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
try
{
// Pfad durchlaufen
var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
Outlook.MAPIFolder current = null;
foreach (Outlook.Store store in ns.Stores)
{
if (store.GetRootFolder().Name == parts[0] ||
store.GetRootFolder().FolderPath.TrimStart('\\') == parts[0])
{
current = store.GetRootFolder();
break;
}
}
if (current == null)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
for (int i = 1; i < parts.Length; i++)
{
bool found = false;
foreach (Outlook.MAPIFolder sub in current.Folders)
{
if (sub.Name == parts[i])
{
current = sub;
found = true;
break;
}
}
if (!found)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
}
return current;
}
catch
{
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
}
}
public List<UnifiedContact> GetContacts(string folderPath)
{
var contacts = new List<UnifiedContact>();
try
{
var folder = GetFolderByPath(folderPath);
var items = folder.Items;
foreach (var item in items)
{
if (item is Outlook.ContactItem ci)
{
contacts.Add(MapFromOutlook(ci));
Marshal.ReleaseComObject(ci);
}
}
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 ci = (Outlook.ContactItem)folder.Items.Add(Outlook.OlItemType.olContactItem);
MapToOutlook(contact, ci);
ci.Save();
contact.OutlookEntryId = ci.EntryID;
Marshal.ReleaseComObject(ci);
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");
var ci = (Outlook.ContactItem)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");
var ci = (Outlook.ContactItem)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 UnifiedContact MapFromOutlook(Outlook.ContactItem ci)
{
return new UnifiedContact
{
OutlookEntryId = ci.EntryID ?? "",
FirstName = ci.FirstName ?? "",
LastName = ci.LastName ?? "",
Company = ci.CompanyName ?? "",
JobTitle = ci.JobTitle ?? "",
Email = ci.Email1Address ?? "",
EmailSecondary = ci.Email2Address ?? "",
PhoneWork = ci.BusinessTelephoneNumber ?? "",
PhoneMobile = ci.MobileTelephoneNumber ?? "",
PhoneHome = ci.HomeTelephoneNumber ?? "",
Fax = ci.BusinessFaxNumber ?? "",
Street = ci.BusinessAddressStreet ?? "",
City = ci.BusinessAddressCity ?? "",
PostalCode = ci.BusinessAddressPostalCode ?? "",
State = ci.BusinessAddressState ?? "",
Country = ci.BusinessAddressCountry ?? "",
Website = ci.WebPage ?? "",
Notes = ci.Body ?? "",
Salutation = ci.Title ?? "",
Title = ci.Suffix ?? "",
Birthday = ci.Birthday != DateTime.MinValue && ci.Birthday.Year > 1900
? ci.Birthday.ToString("yyyy-MM-dd") : ""
};
}
private void MapToOutlook(UnifiedContact contact, Outlook.ContactItem 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 { }
}
Marshal.ReleaseComObject(_outlookApp);
_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,326 @@
using System;
using System.Collections.Generic;
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>();
books.Add(new StarfaceAddressBook
{
Type = "central",
Name = "Zentrales Adressbuch"
});
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
Name = "Persoenliches Adressbuch"
});
}
// Tags als virtuelle Adressbuecher
try
{
var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags");
if (resp.IsSuccessStatusCode)
{
var tags = JArray.Parse(await resp.Content.ReadAsStringAsync());
foreach (var tag in tags)
{
books.Add(new StarfaceAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = $"Tag: {tag["name"]}"
});
}
}
}
catch { }
return books;
}
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
{
var contacts = new List<UnifiedContact>();
int page = 0;
const int pageSize = 200;
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 array = JArray.Parse(await resp.Content.ReadAsStringAsync());
if (array.Count == 0) break;
foreach (var item in array)
contacts.Add(MapFromStarface(item));
if (array.Count < pageSize) break;
page++;
}
return contacts;
}
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
{
var sfContact = MapToStarface(contact);
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
if (!resp.IsSuccessStatusCode) return null;
var created = JObject.Parse(await resp.Content.ReadAsStringAsync());
return MapFromStarface(created);
}
public async Task<bool> UpdateContactAsync(string contactId, UnifiedContact contact, StarfaceAddressBook book)
{
var sfContact = MapToStarface(contact);
sfContact["id"] = contactId;
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json");
var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content);
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() ?? "";
var attrs = new Dictionary<string, string>();
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 key = attr["displayKey"]?.ToString() ?? "";
var val = attr["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(val))
attrs[key] = val;
}
}
}
contact.FirstName = attrs.GetValueOrDefault("NAME", "");
contact.LastName = attrs.GetValueOrDefault("SURNAME", "");
contact.Company = attrs.GetValueOrDefault("COMPANY", "");
contact.JobTitle = attrs.GetValueOrDefault("JOB_TITLE", "");
contact.Email = attrs.GetValueOrDefault("EMAIL", "");
contact.PhoneWork = attrs.GetValueOrDefault("OFFICE_PHONE_NUMBER", "");
contact.PhoneMobile = attrs.GetValueOrDefault("MOBILE_PHONE_NUMBER", "");
contact.PhoneHome = attrs.GetValueOrDefault("PRIVATE_PHONE_NUMBER", "");
contact.Fax = attrs.GetValueOrDefault("FAX_NUMBER", "");
contact.Street = attrs.GetValueOrDefault("STREET", "");
contact.City = attrs.GetValueOrDefault("CITY", "");
contact.PostalCode = attrs.GetValueOrDefault("POSTAL_CODE", "");
contact.State = attrs.GetValueOrDefault("STATE", "");
contact.Country = attrs.GetValueOrDefault("COUNTRY", "");
contact.Website = attrs.GetValueOrDefault("URL", "");
contact.Notes = attrs.GetValueOrDefault("NOTE", "");
contact.Salutation = attrs.GetValueOrDefault("SALUTATION", "");
contact.Title = attrs.GetValueOrDefault("TITLE", "");
contact.Birthday = attrs.GetValueOrDefault("BIRTHDAY", "");
return contact;
}
private JObject MapToStarface(UnifiedContact contact)
{
var attrs = new JArray();
void AddAttr(string displayKey, string name, string value)
{
if (!string.IsNullOrEmpty(value))
attrs.Add(new JObject { ["displayKey"] = displayKey, ["name"] = name, ["value"] = 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);
return new JObject
{
["id"] = contact.StarfaceId ?? "",
["blocks"] = new JArray
{
new JObject
{
["name"] = "contact",
["resourceKey"] = "contact",
["attributes"] = attrs
}
}
};
}
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,226 @@
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);
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
// Erst E-Mail-Match
if (!string.IsNullOrEmpty(contact.Email))
{
var byEmail = candidates.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.Email) &&
c.Email.Equals(contact.Email, StringComparison.OrdinalIgnoreCase));
if (byEmail != null) return byEmail;
}
// Dann Name-Match
if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
{
var byName = candidates.FirstOrDefault(c =>
c.FirstName.Equals(contact.FirstName, StringComparison.OrdinalIgnoreCase) &&
c.LastName.Equals(contact.LastName, StringComparison.OrdinalIgnoreCase) &&
(!string.IsNullOrEmpty(c.FirstName) || !string.IsNullOrEmpty(c.LastName)));
if (byName != null) return byName;
}
return null;
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
{
ProfileName = profile.Name,
Timestamp = DateTime.Now.ToString("o")
};
try
{
// Starface verbinden
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
{
var loginOk = await starface.LoginAsync();
if (!loginOk)
{
result.ErrorMessages.Add("Starface-Login fehlgeschlagen");
result.Errors++;
return result;
}
var mappings = _profileManager.GetMappings(profile.Id);
var mappingByOutlook = mappings.ToDictionary(m => m.OutlookEntryId, m => m);
var mappingByStarface = mappings.ToDictionary(m => m.StarfaceId, m => m);
// Kontakte laden
Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
Log($"{outlookContacts.Count} Outlook-Kontakte geladen");
Log("Lade Starface-Kontakte...");
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Outlook -> Starface
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.OutlookToStarface)
{
Log("Synchronisiere Outlook -> Starface...");
foreach (var oc in outlookContacts)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(oc.OutlookEntryId))
mappingByOutlook.TryGetValue(oc.OutlookEntryId, out existing);
if (existing != null)
{
var hash = oc.GetHash();
if (hash != existing.LastSyncHash)
{
if (await starface.UpdateContactAsync(existing.StarfaceId, oc, profile.StarfaceAddressBook))
{
existing.LastSyncHash = hash;
result.Updated++;
}
}
}
else
{
var match = FindMatch(oc, starfaceContacts);
if (match != null && !string.IsNullOrEmpty(match.StarfaceId))
{
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Updated++;
}
}
else
{
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = created.StarfaceId,
LastSyncHash = oc.GetHash()
});
result.Created++;
}
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{oc.DisplayName}: {ex.Message}");
}
}
}
// Starface -> Outlook
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
Log("Synchronisiere Starface -> Outlook...");
foreach (var sc in starfaceContacts)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(sc.StarfaceId))
mappingByStarface.TryGetValue(sc.StarfaceId, out existing);
if (existing != null)
{
var hash = sc.GetHash();
if (hash != existing.LastSyncHash)
{
if (_outlookService.UpdateContact(existing.OutlookEntryId, sc))
{
existing.LastSyncHash = hash;
result.Updated++;
}
}
}
else
{
var match = FindMatch(sc, outlookContacts);
if (match != null && !string.IsNullOrEmpty(match.OutlookEntryId))
{
if (_outlookService.UpdateContact(match.OutlookEntryId, sc))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Updated++;
}
}
else
{
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
result.Created++;
}
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{sc.DisplayName}: {ex.Message}");
}
}
}
_profileManager.UpdateLastSync(profile.Id);
_profileManager.SaveMappings(profile.Id, mappings);
await starface.LogoutAsync();
Log("Synchronisation abgeschlossen!");
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"Allgemeiner Fehler: {ex.Message}");
}
return result;
}
}
}
@@ -0,0 +1,25 @@
<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.5</Version>
<AssemblyVersion>0.0.0.5</AssemblyVersion>
<FileVersion>0.0.0.5</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" />
<PackageReference Include="Microsoft.Office.Interop.Outlook" Version="15.0.4797.1004" />
</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.5",
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;
}
}
}
+364
View File
@@ -0,0 +1,364 @@
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, _btnSettings, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
public MainForm()
{
InitializeComponent();
SetupTrayIcon();
SetupAutoSync();
RefreshProfileList();
// Minimiert starten falls in Einstellungen aktiviert
var settings = UserSettings.Load();
if (settings.StartMinimized)
{
WindowState = FormWindowState.Minimized;
ShowInTaskbar = false;
Visible = false;
}
}
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(620, 450);
MinimumSize = new Size(500, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
// 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 = "Jetzt synchronisieren", Width = 150, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnSettings = new Button { Text = "Einstellungen", Width = 100, 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, _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();
_trayMenu.Items.Add("Oeffnen", null, (s, e) => ShowMainWindow());
_trayMenu.Items.Add("-");
// Schnell-Sync fuer jedes Profil
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled))
{
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("Beenden", null, (s, e) => ExitApplication());
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = SystemIcons.Application, // Placeholder, wird durch eigenes Icon ersetzt
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
}
private void SetupAutoSync()
{
_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
SetupTrayIcon();
}
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 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)
{
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}");
}
}
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,344 @@
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
_pm.UpdateProfile(profile);
DialogResult = DialogResult.OK;
Close();
}
}
}
@@ -0,0 +1,62 @@
using System.Drawing;
using System.Windows.Forms;
using StarfaceOutlookSync.Models;
namespace StarfaceOutlookSync.UI
{
public class SettingsForm : Form
{
private CheckBox _chkStartMinimized;
private Button _btnSave, _btnCancel;
private readonly UserSettings _settings;
public SettingsForm()
{
_settings = UserSettings.Load();
InitializeComponent();
}
private void InitializeComponent()
{
Text = "Einstellungen";
Size = new Size(350, 180);
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
};
_btnSave = new Button
{
Text = "Speichern", Left = 80, Top = 100, Width = 85, Height = 28,
DialogResult = DialogResult.None
};
_btnSave.Click += (s, e) => Save();
_btnCancel = new Button
{
Text = "Abbrechen", Left = 174, Top = 100, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel
};
Controls.AddRange(new Control[] { _chkStartMinimized, _btnSave, _btnCancel });
AcceptButton = _btnSave;
CancelButton = _btnCancel;
}
private void Save()
{
_settings.StartMinimized = _chkStartMinimized.Checked;
_settings.Save();
DialogResult = DialogResult.OK;
Close();
}
}
}
@@ -0,0 +1,127 @@
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.Marquee
};
_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,
};
};