Compare commits

...

45 Commits

Author SHA1 Message Date
duffyduck b07a3b3a87 Fix critical delete bug: don't mass-delete on EntryID change
Two fixes for delete propagation in Phase 1:

1. When Outlook contact not found by EntryID, try to re-match
   by name/email/phone before assuming it was deleted. Outlook
   can change EntryIDs on restart or profile changes, causing
   the sync to think ALL contacts were deleted.

2. When Starface contact not found in current list, DON'T delete
   from Outlook. The contact may belong to a different address
   book. Just drop the mapping and let Phase 2/3 re-link it.

These changes make delete propagation much safer and prevent
accidental mass-deletion of contacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:03:51 +02:00
duffyduck f620e96d23 Document Outlook security prompt suppression in README
Include step-by-step instructions for both standalone PCs and
domain/Terminal Server environments where GPO locks the setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:55:41 +02:00
duffyduck e8731a87d4 Release v0.0.0.23 2026-04-03 19:50:03 +02:00
duffyduck 53ca4611d1 Write Outlook security keys to HKLM for domain environments
On domain PCs, HKCU policies are controlled by GPO and the
Trust Center settings are greyed out. Now also writes to HKLM
(requires admin rights) which overrides GPO settings.

Shows orange hint in settings when GPO lock is detected:
"Auf Domaenen-PCs: App einmalig als Admin starten!"

The app tries all 8 combinations: HKCU/HKLM x Policies/direct
x 16.0/15.0. Silently skips paths where permissions are denied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:48:09 +02:00
duffyduck df13ddf6b1 Release v0.0.0.22 2026-04-03 19:38:02 +02:00
duffyduck c08a625348 Write Outlook security keys to both Policies and normal user path
On Terminal Servers, normal users cannot write to HKCU\Software\
Policies. Now also writes to HKCU\Software\Microsoft\Office\...\
Security which is always writable and also read by Outlook.

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

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

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

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

Outlook must be restarted after changing this setting.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:56:34 +02:00
13 changed files with 1187 additions and 331 deletions

View File

@ -42,6 +42,32 @@ Windows-Anwendung zur bidirektionalen Synchronisation von Kontakten zwischen Mic
5. Starface-Adressbuch und Outlook-Kontaktordner waehlen
6. Speichern und "Jetzt synchronisieren"
### Outlook-Sicherheitsabfrage unterdruecken
Beim Zugriff auf Outlook-Kontakte zeigt Outlook standardmaessig einen
Sicherheitsdialog ("Ein Programm versucht auf Ihre E-Mail-Adressinformationen
zuzugreifen"). Dieser kann in den Einstellungen der App deaktiviert werden:
1. In der App auf "Einstellungen" klicken
2. "Outlook-Sicherheitsabfrage automatisch erlauben" aktivieren
3. Speichern
**Auf Domaenen-PCs / Terminal Servern:**
Die Outlook-Sicherheitseinstellungen werden dort per Gruppenrichtlinie (GPO)
gesteuert und sind im Trust Center ausgegraut. In diesem Fall muss die App
**einmalig als Administrator** gestartet werden, damit die Registry-Keys
unter HKLM geschrieben werden koennen:
1. Rechtsklick auf die App -> "Als Administrator ausfuehren"
2. Einstellungen -> "Outlook-Sicherheitsabfrage automatisch erlauben" aktivieren
3. Speichern und App schliessen
4. Outlook neu starten
5. App kann danach wieder normal (ohne Admin) gestartet werden
Die Einstellung bleibt dauerhaft bestehen und gilt fuer alle Benutzer
auf dem Rechner.
### Deinstallation
Ueber Windows Einstellungen -> Apps oder die Systemsteuerung.

View File

@ -2,7 +2,7 @@
; Erfordert Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
#define MyAppName "Starface Outlook Sync"
#define MyAppVersion "0.0.0.5"
#define MyAppVersion "0.0.0.23"
#define MyAppPublisher "HackerSoft - Hacker-Net Telekommunikation"
#define MyAppURL "https://www.hacker-net.de"
#define MyAppExeName "StarfaceOutlookSync.exe"

View File

@ -1,5 +1,6 @@
using System;
using System.IO;
using Microsoft.Win32;
using Newtonsoft.Json;
namespace StarfaceOutlookSync.Models
@ -7,6 +8,8 @@ namespace StarfaceOutlookSync.Models
public class UserSettings
{
public bool StartMinimized { get; set; } = false;
public bool SyncOnStart { get; set; } = false;
public bool AutoAcceptOutlookPrompt { get; set; } = false;
private static readonly string SettingsFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@ -32,6 +35,86 @@ namespace StarfaceOutlookSync.Models
File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(this, Formatting.Indented));
}
catch { }
ApplyOutlookSecuritySetting();
}
public void ApplyOutlookSecuritySetting()
{
var versions = new[] { "16.0", "15.0" };
var securityValues = new (string name, int value)[]
{
("ObjectModelGuard", 2),
("PromptOOMAddressBookAccess", 2),
("PromptOOMAddressInformationAccess", 2),
("PromptOOMSend", 2),
("PromptOOMSaveAs", 2),
("PromptOOMFormulaAccess", 2),
("PromptOOMCustomAction", 2),
("PromptSimpleMAPISend", 2),
("PromptSimpleMAPINameResolve", 2),
("PromptSimpleMAPIOpenMessage", 2),
("AdminSecurityMode", 3),
};
// In alle moeglichen Pfade schreiben (HKCU + HKLM, Policies + direkt)
var roots = new[] { Registry.CurrentUser, Registry.LocalMachine };
var prefixes = new[]
{
@"Software\Policies\Microsoft\Office",
@"Software\Microsoft\Office"
};
foreach (var ver in versions)
{
foreach (var root in roots)
{
foreach (var prefix in prefixes)
{
var regPath = $@"{prefix}\{ver}\Outlook\Security";
try
{
if (AutoAcceptOutlookPrompt)
{
var key = root.CreateSubKey(regPath);
if (key != null)
{
foreach (var (name, value) in securityValues)
key.SetValue(name, value, RegistryValueKind.DWord);
key.Close();
}
}
else
{
try { root.DeleteSubKey(regPath, false); } catch { }
}
}
catch { } // Kein Fehler wenn Rechte fehlen - naechsten Pfad versuchen
}
}
}
}
/// <summary>
/// Prueft ob die Outlook-Sicherheitseinstellung per GPO blockiert wird.
/// </summary>
public static bool IsOutlookSecurityLockedByPolicy()
{
try
{
// Wenn HKLM Policies gesetzt sind und wir dort nicht schreiben koennen
var key = Registry.LocalMachine.OpenSubKey(
@"Software\Policies\Microsoft\Office\16.0\Outlook\Security", false);
if (key != null)
{
var val = key.GetValue("AdminSecurityMode");
key.Close();
if (val != null) return true;
}
}
catch { }
return false;
}
}
}

View File

@ -2,45 +2,76 @@ using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using StarfaceOutlookSync.Models;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// Zugriff auf Outlook-Kontakte per dynamic COM.
/// Funktioniert mit jeder Outlook Classic Version (2013-2024)
/// ohne Abhaengigkeit von einer bestimmten Interop-DLL.
/// </summary>
public class OutlookContactsService : IDisposable
{
private Outlook.Application _outlookApp;
private dynamic _outlookApp;
private bool _weStartedOutlook;
// Marshal.GetActiveObject existiert nicht in .NET 8, daher P/Invoke
// OlDefaultFolders.olFolderContacts = 10
private const int OlFolderContacts = 10;
// OlItemType.olContactItem = 2
private const int OlContactItem = 2;
[DllImport("oleaut32.dll", PreserveSig = false)]
private static extern void GetActiveObject([MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, IntPtr pvReserved, [MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
private static extern void GetActiveObject(
[MarshalAs(UnmanagedType.LPStruct)] Guid rclsid,
IntPtr pvReserved,
[MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
private static object GetActiveComObject(string progId)
{
var clsid = Type.GetTypeFromProgID(progId, true).GUID;
GetActiveObject(clsid, IntPtr.Zero, out var obj);
var type = Type.GetTypeFromProgID(progId, false);
if (type == null) return null;
GetActiveObject(type.GUID, IntPtr.Zero, out var obj);
return obj;
}
private Outlook.Application GetOutlookApp()
private dynamic GetOutlookApp()
{
if (_outlookApp != null) return _outlookApp;
// Versuch 1: Laufende Outlook-Instanz finden
try
{
// Versuche laufende Outlook-Instanz zu finden
_outlookApp = (Outlook.Application)GetActiveComObject("Outlook.Application");
_weStartedOutlook = false;
}
catch
var obj = GetActiveComObject("Outlook.Application");
if (obj != null)
{
// Outlook starten falls nicht laufend
_outlookApp = new Outlook.Application();
_weStartedOutlook = true;
}
_outlookApp = obj;
_weStartedOutlook = false;
return _outlookApp;
}
}
catch { }
// Versuch 2: Outlook per COM starten
try
{
var outlookType = Type.GetTypeFromProgID("Outlook.Application", false);
if (outlookType != null)
{
_outlookApp = Activator.CreateInstance(outlookType);
_weStartedOutlook = true;
return _outlookApp;
}
}
catch { }
throw new InvalidOperationException(
"Outlook Classic konnte nicht gefunden werden.\n\n" +
"Moegliche Ursachen:\n" +
"- Outlook Classic ist nicht installiert\n" +
"- Outlook Classic ist nicht gestartet\n" +
"- Das neue Outlook wird verwendet (wird noch nicht unterstuetzt)\n\n" +
"Bitte Outlook Classic starten und erneut versuchen.");
}
public List<string> GetContactFolderPaths()
{
@ -50,18 +81,21 @@ namespace StarfaceOutlookSync.Services
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Alle Stores durchgehen (jedes Konto, jede PST-Datei etc.)
foreach (Outlook.Store store in ns.Stores)
// Alle Stores durchgehen
var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++)
{
try
{
var store = stores[i];
var rootFolder = store.GetRootFolder();
FindContactFoldersRecursive(rootFolder, folders);
Marshal.ReleaseComObject(rootFolder);
Marshal.ReleaseComObject(store);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning store '{store.DisplayName}': {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Error scanning store {i}: {ex.Message}");
}
}
@ -70,121 +104,212 @@ namespace StarfaceOutlookSync.Services
{
try
{
var defaultFolder = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
folders.Add(defaultFolder.FolderPath);
var defaultFolder = ns.GetDefaultFolder(OlFolderContacts);
folders.Add((string)defaultFolder.FolderPath);
Marshal.ReleaseComObject(defaultFolder);
}
catch { }
}
Marshal.ReleaseComObject(stores);
Marshal.ReleaseComObject(ns);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error getting folders: {ex.Message}");
throw;
}
return folders;
}
private void FindContactFoldersRecursive(Outlook.MAPIFolder folder, List<string> paths)
private void FindContactFoldersRecursive(dynamic folder, List<string> paths)
{
try
{
// Kontaktordner erkennen: DefaultItemType ODER Ordnername enthaelt "Kontakt"/"Contact"
if (folder.DefaultItemType == Outlook.OlItemType.olContactItem)
if ((int)folder.DefaultItemType == OlContactItem)
{
if (!paths.Contains(folder.FolderPath))
paths.Add(folder.FolderPath);
string path = folder.FolderPath;
if (!paths.Contains(path))
paths.Add(path);
}
// Alle Unterordner durchsuchen
foreach (Outlook.MAPIFolder sub in folder.Folders)
var subFolders = folder.Folders;
for (int i = 1; i <= (int)subFolders.Count; i++)
{
try
{
var sub = subFolders[i];
FindContactFoldersRecursive(sub, paths);
}
catch { }
finally
{
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subFolders);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error scanning folder '{folder.Name}': {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Error scanning folder: {ex.Message}");
}
}
private Outlook.MAPIFolder GetFolderByPath(string folderPath)
private dynamic FindFolderByPath(dynamic folder, string targetPath)
{
try
{
string currentPath = folder.FolderPath;
if (currentPath == targetPath)
return folder;
var subs = folder.Folders;
for (int i = 1; i <= (int)subs.Count; i++)
{
try
{
var sub = subs[i];
var match = FindFolderByPath(sub, targetPath);
if (match != null) return match;
Marshal.ReleaseComObject(sub);
}
catch { }
}
Marshal.ReleaseComObject(subs);
}
catch { }
return null;
}
private dynamic GetFolderByPath(string folderPath)
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
// Standard-Kontaktordner als Fallback
if (string.IsNullOrEmpty(folderPath))
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
return ns.GetDefaultFolder(OlFolderContacts);
try
{
// Pfad durchlaufen
// Versuch 1: Alle Kontaktordner durchsuchen und per FolderPath matchen
var stores = ns.Stores;
for (int i = 1; i <= (int)stores.Count; i++)
{
try
{
var store = stores[i];
var root = store.GetRootFolder();
var match = FindFolderByPath(root, folderPath);
if (match != null)
{
Marshal.ReleaseComObject(stores);
return match;
}
Marshal.ReleaseComObject(root);
Marshal.ReleaseComObject(store);
}
catch { }
}
Marshal.ReleaseComObject(stores);
// Versuch 2: Namespace.Folders direkt navigieren
var parts = folderPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
Outlook.MAPIFolder current = null;
foreach (Outlook.Store store in ns.Stores)
if (parts.Length >= 1)
{
if (store.GetRootFolder().Name == parts[0] ||
store.GetRootFolder().FolderPath.TrimStart('\\') == parts[0])
var topFolders = ns.Folders;
for (int i = 1; i <= (int)topFolders.Count; i++)
{
current = store.GetRootFolder();
break;
}
}
if (current == null)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
for (int i = 1; i < parts.Length; i++)
var topFolder = topFolders[i];
if ((string)topFolder.Name == parts[0])
{
dynamic current = topFolder;
for (int p = 1; p < parts.Length; p++)
{
bool found = false;
foreach (Outlook.MAPIFolder sub in current.Folders)
var subs = current.Folders;
for (int j = 1; j <= (int)subs.Count; j++)
{
if (sub.Name == parts[i])
var sub = subs[j];
if ((string)sub.Name == parts[p])
{
current = sub;
found = true;
break;
}
Marshal.ReleaseComObject(sub);
}
if (!found)
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
Marshal.ReleaseComObject(subs);
if (!found) { current = null; break; }
}
if (current != null)
{
Marshal.ReleaseComObject(topFolders);
return current;
}
}
Marshal.ReleaseComObject(topFolder);
}
Marshal.ReleaseComObject(topFolders);
}
return current;
System.Diagnostics.Debug.WriteLine($"Folder not found by path: {folderPath}, using default");
return ns.GetDefaultFolder(OlFolderContacts);
}
catch
{
return ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderContacts);
return ns.GetDefaultFolder(OlFolderContacts);
}
}
public List<UnifiedContact> GetContacts(string folderPath)
{
var contacts = new List<UnifiedContact>();
try
{
var folder = GetFolderByPath(folderPath);
var items = folder.Items;
System.Diagnostics.Debug.WriteLine($"Reading contacts from: {(string)folder.FolderPath}");
foreach (var item in items)
var items = folder.Items;
int count = (int)items.Count;
System.Diagnostics.Debug.WriteLine($"Items count: {count}");
for (int i = 1; i <= count; i++)
{
if (item is Outlook.ContactItem ci)
dynamic item = null;
try
{
contacts.Add(MapFromOutlook(ci));
Marshal.ReleaseComObject(ci);
item = items[i];
int itemClass = -1;
try { itemClass = (int)item.Class; } catch { }
// olContact = 40, aber manche Kontakte melden Class anders
// Versuch einfach die Kontakt-Felder zu lesen
if (itemClass == 40)
{
contacts.Add(MapFromOutlook(item));
}
else
{
// Fallback: Pruefen ob es trotzdem ein Kontakt ist
try
{
string eid = item.EntryID;
string fn = item.FirstName;
string ln = item.LastName;
// Hat Kontakt-Felder -> ist ein Kontakt
contacts.Add(MapFromOutlook(item));
}
catch
{
// Kein Kontakt, ueberspringen
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error reading item {i}: {ex.Message}");
}
finally
{
if (item != null) try { Marshal.ReleaseComObject(item); } catch { }
}
}
@ -204,7 +329,8 @@ namespace StarfaceOutlookSync.Services
try
{
var folder = GetFolderByPath(folderPath);
var ci = (Outlook.ContactItem)folder.Items.Add(Outlook.OlItemType.olContactItem);
var items = folder.Items;
dynamic ci = items.Add(OlContactItem);
MapToOutlook(contact, ci);
ci.Save();
@ -212,6 +338,7 @@ namespace StarfaceOutlookSync.Services
contact.OutlookEntryId = ci.EntryID;
Marshal.ReleaseComObject(ci);
Marshal.ReleaseComObject(items);
Marshal.ReleaseComObject(folder);
return contact;
@ -229,7 +356,7 @@ namespace StarfaceOutlookSync.Services
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
var ci = (Outlook.ContactItem)ns.GetItemFromID(entryId);
dynamic ci = ns.GetItemFromID(entryId);
MapToOutlook(contact, ci);
ci.Save();
@ -252,7 +379,7 @@ namespace StarfaceOutlookSync.Services
{
var app = GetOutlookApp();
var ns = app.GetNamespace("MAPI");
var ci = (Outlook.ContactItem)ns.GetItemFromID(entryId);
dynamic ci = ns.GetItemFromID(entryId);
ci.Delete();
Marshal.ReleaseComObject(ci);
@ -267,36 +394,58 @@ namespace StarfaceOutlookSync.Services
}
}
private UnifiedContact MapFromOutlook(Outlook.ContactItem ci)
private static string SafeGet(dynamic ci, string property)
{
try
{
object val = ci.GetType().InvokeMember(property,
System.Reflection.BindingFlags.GetProperty, null, ci, null);
return val?.ToString() ?? "";
}
catch { return ""; }
}
private UnifiedContact MapFromOutlook(dynamic ci)
{
return new UnifiedContact
{
OutlookEntryId = ci.EntryID ?? "",
FirstName = ci.FirstName ?? "",
LastName = ci.LastName ?? "",
Company = ci.CompanyName ?? "",
JobTitle = ci.JobTitle ?? "",
Email = ci.Email1Address ?? "",
EmailSecondary = ci.Email2Address ?? "",
PhoneWork = ci.BusinessTelephoneNumber ?? "",
PhoneMobile = ci.MobileTelephoneNumber ?? "",
PhoneHome = ci.HomeTelephoneNumber ?? "",
Fax = ci.BusinessFaxNumber ?? "",
Street = ci.BusinessAddressStreet ?? "",
City = ci.BusinessAddressCity ?? "",
PostalCode = ci.BusinessAddressPostalCode ?? "",
State = ci.BusinessAddressState ?? "",
Country = ci.BusinessAddressCountry ?? "",
Website = ci.WebPage ?? "",
Notes = ci.Body ?? "",
Salutation = ci.Title ?? "",
Title = ci.Suffix ?? "",
Birthday = ci.Birthday != DateTime.MinValue && ci.Birthday.Year > 1900
? ci.Birthday.ToString("yyyy-MM-dd") : ""
OutlookEntryId = SafeGet(ci, "EntryID"),
FirstName = SafeGet(ci, "FirstName"),
LastName = SafeGet(ci, "LastName"),
Company = SafeGet(ci, "CompanyName"),
JobTitle = SafeGet(ci, "JobTitle"),
Email = SafeGet(ci, "Email1Address"),
EmailSecondary = SafeGet(ci, "Email2Address"),
PhoneWork = SafeGet(ci, "BusinessTelephoneNumber"),
PhoneMobile = SafeGet(ci, "MobileTelephoneNumber"),
PhoneHome = SafeGet(ci, "HomeTelephoneNumber"),
Fax = SafeGet(ci, "BusinessFaxNumber"),
Street = SafeGet(ci, "BusinessAddressStreet"),
City = SafeGet(ci, "BusinessAddressCity"),
PostalCode = SafeGet(ci, "BusinessAddressPostalCode"),
State = SafeGet(ci, "BusinessAddressState"),
Country = SafeGet(ci, "BusinessAddressCountry"),
Website = SafeGet(ci, "WebPage"),
Notes = SafeGet(ci, "Body"),
Salutation = SafeGet(ci, "Title"),
Birthday = GetBirthdayString(ci)
};
}
private void MapToOutlook(UnifiedContact contact, Outlook.ContactItem ci)
private string GetBirthdayString(dynamic ci)
{
try
{
object val = ci.GetType().InvokeMember("Birthday",
System.Reflection.BindingFlags.GetProperty, null, ci, null);
if (val is DateTime bday && bday.Year > 1900 && bday != DateTime.MinValue)
return bday.ToString("yyyy-MM-dd");
}
catch { }
return "";
}
private void MapToOutlook(UnifiedContact contact, dynamic ci)
{
ci.FirstName = contact.FirstName;
ci.LastName = contact.LastName;
@ -333,7 +482,7 @@ namespace StarfaceOutlookSync.Services
{
try { _outlookApp.Quit(); } catch { }
}
Marshal.ReleaseComObject(_outlookApp);
try { Marshal.ReleaseComObject(_outlookApp); } catch { }
_outlookApp = null;
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
@ -115,51 +116,74 @@ namespace StarfaceOutlookSync.Services
{
var books = new List<StarfaceAddressBook>();
books.Add(new StarfaceAddressBook
{
Type = "central",
Name = "Zentrales Adressbuch"
});
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
Name = "Persoenliches Adressbuch"
});
}
// Tags als virtuelle Adressbuecher
// Alle Tags laden - die Starface nutzt Tags als Adressbuch-Zuordnung
var allTags = new JArray();
try
{
var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags");
if (resp.IsSuccessStatusCode)
{
var tags = JArray.Parse(await resp.Content.ReadAsStringAsync());
foreach (var tag in tags)
{
books.Add(new StarfaceAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = $"Tag: {tag["name"]}"
});
}
allTags = JArray.Parse(await resp.Content.ReadAsStringAsync());
OnDebug?.Invoke($"Gefundene Tags: {allTags.Count}");
foreach (var t in allTags)
OnDebug?.Invoke($" Tag: {t["name"]} (id: {t["id"]}, alias: {t["alias"]}, owner: {t["owner"]})");
}
}
catch { }
// Zentrales Adressbuch (folder/all)
var allTag = allTags.FirstOrDefault(t => t["name"]?.ToString() == "folder/all"
|| t["alias"]?.ToString()?.Contains("folder.all") == true);
books.Add(new StarfaceAddressBook
{
Type = "central",
TagId = allTag?["id"]?.ToString() ?? "",
Name = "Zentrales Adressbuch"
});
// Persoenliches Adressbuch (folder/private mit owner = userId)
var userId = await GetCurrentUserIdAsync();
if (!string.IsNullOrEmpty(userId))
{
var privateTag = allTags.FirstOrDefault(t =>
(t["name"]?.ToString() == "folder/private" || t["alias"]?.ToString()?.Contains("folder.private") == true)
&& t["owner"]?.ToString() == userId);
books.Add(new StarfaceAddressBook
{
Type = "user",
UserId = userId,
TagId = privateTag?["id"]?.ToString() ?? "",
Name = "Persoenliches Adressbuch"
});
}
// Alle weiteren Tags als Adressbuecher anbieten
foreach (var tag in allTags)
{
var tagName = tag["name"]?.ToString() ?? "";
// folder/all und folder/private bereits oben erfasst
if (tagName == "folder/all" || tagName == "folder/private") continue;
books.Add(new StarfaceAddressBook
{
Type = "tag",
TagId = tag["id"]?.ToString() ?? "",
Name = tagName
});
}
return books;
}
public event Action<string> OnDebug;
public async Task<List<UnifiedContact>> GetContactsAsync(StarfaceAddressBook book)
{
var contacts = new List<UnifiedContact>();
int page = 0;
const int pageSize = 200;
bool firstPage = true;
while (true)
{
@ -172,11 +196,68 @@ namespace StarfaceOutlookSync.Services
var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}");
if (!resp.IsSuccessStatusCode) break;
var array = JArray.Parse(await resp.Content.ReadAsStringAsync());
var body = await resp.Content.ReadAsStringAsync();
JArray array;
// Die API gibt je nach Version ein Array oder ein Objekt mit "items" zurueck
var token = JToken.Parse(body);
if (token is JArray directArray)
{
array = directArray;
}
else if (token is JObject obj)
{
// Versuche gaengige Felder: items, contacts, data, results
array = (obj["items"] ?? obj["contacts"] ?? obj["data"] ?? obj["results"]) as JArray;
if (array == null)
{
// Einzelnes Kontakt-Objekt? Dann in Array wrappen
if (obj["id"] != null && obj["blocks"] != null)
{
array = new JArray { obj };
}
else
{
System.Diagnostics.Debug.WriteLine($"Unerwartete Starface-Antwort: {body.Substring(0, Math.Min(200, body.Length))}");
break;
}
}
}
else
{
break;
}
if (array.Count == 0) break;
OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste");
// Die Listen-API gibt nur Summary zurueck.
// Jeden Kontakt einzeln abrufen fuer alle Felder.
foreach (var item in array)
contacts.Add(MapFromStarface(item));
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
try
{
var detailResp = await _http.GetAsync($"{_baseUrl}/contacts/{id}");
if (detailResp.IsSuccessStatusCode)
{
var detailBody = await detailResp.Content.ReadAsStringAsync();
var detailObj = JObject.Parse(detailBody);
if (firstPage)
{
OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{detailObj.ToString(Formatting.Indented)}");
firstPage = false;
}
contacts.Add(MapFromStarface(detailObj));
}
}
catch { }
}
if (array.Count < pageSize) break;
page++;
@ -188,15 +269,34 @@ namespace StarfaceOutlookSync.Services
public async Task<UnifiedContact> CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book)
{
var sfContact = MapToStarface(contact);
// Tag zuweisen - die Starface verlangt dass jeder Kontakt einem Tag zugeordnet ist
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray
{
new JObject { ["id"] = book.TagId }
};
}
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
if (!resp.IsSuccessStatusCode) return null;
var body = sfContact.ToString();
OnDebug?.Invoke($"POST /contacts{query} Body:\n{body}");
var created = JObject.Parse(await resp.Content.ReadAsStringAsync());
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content);
var respBody = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
OnDebug?.Invoke($"POST /contacts fehlgeschlagen: {(int)resp.StatusCode} {resp.StatusCode}\n{respBody}");
return null;
}
var created = JObject.Parse(respBody);
return MapFromStarface(created);
}
@ -204,12 +304,27 @@ namespace StarfaceOutlookSync.Services
{
var sfContact = MapToStarface(contact);
sfContact["id"] = contactId;
// Tag beibehalten
if (!string.IsNullOrEmpty(book.TagId))
{
sfContact["tags"] = new JArray { new JObject { ["id"] = book.TagId } };
}
var query = "";
if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId))
query = $"?userId={book.UserId}";
var content = new StringContent(sfContact.ToString(), Encoding.UTF8, "application/json");
var body = sfContact.ToString();
var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content);
if (!resp.IsSuccessStatusCode)
{
var respBody = await resp.Content.ReadAsStringAsync();
OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}");
}
return resp.IsSuccessStatusCode;
}
@ -224,7 +339,10 @@ namespace StarfaceOutlookSync.Services
var contact = new UnifiedContact();
contact.StarfaceId = item["id"]?.ToString() ?? "";
var attrs = new Dictionary<string, string>();
// Attribute per "name"-Feld mappen (zuverlaessiger als displayKey,
// weil viele Felder USER_DEFINED als displayKey haben)
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byDisplayKey = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var blocks = item["blocks"] as JArray;
if (blocks != null)
{
@ -234,79 +352,123 @@ namespace StarfaceOutlookSync.Services
if (blockAttrs == null) continue;
foreach (var attr in blockAttrs)
{
var key = attr["displayKey"]?.ToString() ?? "";
var name = attr["name"]?.ToString() ?? "";
var displayKey = attr["displayKey"]?.ToString() ?? "";
var val = attr["value"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(val))
attrs[key] = val;
{
if (!string.IsNullOrEmpty(name))
byName[name] = val;
// displayKey nur als Fallback (viele sind USER_DEFINED)
if (!string.IsNullOrEmpty(displayKey) && displayKey != "USER_DEFINED")
byDisplayKey[displayKey] = val;
}
}
}
}
contact.FirstName = attrs.GetValueOrDefault("NAME", "");
contact.LastName = attrs.GetValueOrDefault("SURNAME", "");
contact.Company = attrs.GetValueOrDefault("COMPANY", "");
contact.JobTitle = attrs.GetValueOrDefault("JOB_TITLE", "");
contact.Email = attrs.GetValueOrDefault("EMAIL", "");
contact.PhoneWork = attrs.GetValueOrDefault("OFFICE_PHONE_NUMBER", "");
contact.PhoneMobile = attrs.GetValueOrDefault("MOBILE_PHONE_NUMBER", "");
contact.PhoneHome = attrs.GetValueOrDefault("PRIVATE_PHONE_NUMBER", "");
contact.Fax = attrs.GetValueOrDefault("FAX_NUMBER", "");
contact.Street = attrs.GetValueOrDefault("STREET", "");
contact.City = attrs.GetValueOrDefault("CITY", "");
contact.PostalCode = attrs.GetValueOrDefault("POSTAL_CODE", "");
contact.State = attrs.GetValueOrDefault("STATE", "");
contact.Country = attrs.GetValueOrDefault("COUNTRY", "");
contact.Website = attrs.GetValueOrDefault("URL", "");
contact.Notes = attrs.GetValueOrDefault("NOTE", "");
contact.Salutation = attrs.GetValueOrDefault("SALUTATION", "");
contact.Title = attrs.GetValueOrDefault("TITLE", "");
contact.Birthday = attrs.GetValueOrDefault("BIRTHDAY", "");
// Primaer nach name-Feld mappen, Fallback auf displayKey
string Get(string name, string displayKey = null)
{
if (byName.TryGetValue(name, out var v)) return v;
if (displayKey != null && byDisplayKey.TryGetValue(displayKey, out v)) return v;
return "";
}
contact.FirstName = Get("firstname", "NAME");
contact.LastName = Get("familyname", "SURNAME");
contact.Company = Get("company", "COMPANY");
contact.JobTitle = Get("jobtitle", "JOB_TITLE");
contact.Email = Get("e-mail", "EMAIL");
contact.PhoneWork = Get("phone", "PHONE_NUMBER");
contact.PhoneMobile = Get("mobile", "MOBILE_PHONE_NUMBER");
contact.PhoneHome = Get("homephone", "PRIVATE_PHONE_NUMBER");
contact.Fax = Get("fax", "FAX_NUMBER");
contact.Street = Get("street", "STREET");
contact.City = Get("city", "CITY");
contact.PostalCode = Get("postcode", "POSTAL_CODE");
contact.State = Get("state", "STATE");
contact.Country = Get("country", "COUNTRY");
contact.Website = Get("url", "URL");
contact.Notes = Get("comment", "NOTE");
contact.Salutation = Get("salutation", "SALUTATION");
contact.Title = Get("title", "TITLE");
contact.Birthday = Get("birthday", "BIRTHDAY");
return contact;
}
private JObject MapToStarface(UnifiedContact contact)
{
var attrs = new JArray();
void AddAttr(string displayKey, string name, string value)
JArray MakeAttrs(params (string displayKey, string name, string value)[] fields)
{
if (!string.IsNullOrEmpty(value))
attrs.Add(new JObject { ["displayKey"] = displayKey, ["name"] = name, ["value"] = value });
var arr = new JArray();
foreach (var (dk, n, v) in fields)
{
if (!string.IsNullOrEmpty(v))
arr.Add(new JObject { ["displayKey"] = dk, ["name"] = n, ["value"] = v });
}
return arr;
}
AddAttr("NAME", "firstName", contact.FirstName);
AddAttr("SURNAME", "lastName", contact.LastName);
AddAttr("COMPANY", "company", contact.Company);
AddAttr("JOB_TITLE", "jobTitle", contact.JobTitle);
AddAttr("EMAIL", "email", contact.Email);
AddAttr("OFFICE_PHONE_NUMBER", "businessPhone", contact.PhoneWork);
AddAttr("MOBILE_PHONE_NUMBER", "mobilePhone", contact.PhoneMobile);
AddAttr("PRIVATE_PHONE_NUMBER", "homePhone", contact.PhoneHome);
AddAttr("FAX_NUMBER", "fax", contact.Fax);
AddAttr("STREET", "street", contact.Street);
AddAttr("CITY", "city", contact.City);
AddAttr("POSTAL_CODE", "postalCode", contact.PostalCode);
AddAttr("STATE", "state", contact.State);
AddAttr("COUNTRY", "country", contact.Country);
AddAttr("URL", "website", contact.Website);
AddAttr("NOTE", "notes", contact.Notes);
AddAttr("SALUTATION", "salutation", contact.Salutation);
AddAttr("TITLE", "title", contact.Title);
AddAttr("BIRTHDAY", "birthday", contact.Birthday);
// Block-Struktur wie von der Starface erwartet
var contactBlock = MakeAttrs(
("NAME", "firstname", contact.FirstName),
("SURNAME", "familyname", contact.LastName),
("COMPANY", "company", contact.Company)
);
var addressBlock = MakeAttrs(
("USER_DEFINED", "street", contact.Street),
("POSTAL_CODE", "postcode", contact.PostalCode),
("USER_DEFINED", "city", contact.City),
("USER_DEFINED", "state", contact.State),
("USER_DEFINED", "country", contact.Country)
);
var phoneBlock = MakeAttrs(
("PHONE_NUMBER", "phone", contact.PhoneWork),
("PRIVATE_PHONE_NUMBER", "homephone", contact.PhoneHome),
("MOBILE_PHONE_NUMBER", "mobile", contact.PhoneMobile),
("FAX_NUMBER", "fax", contact.Fax)
);
var emailBlock = MakeAttrs(
("EMAIL", "e-mail", contact.Email),
("URL", "url", contact.Website),
("USER_DEFINED", "comment", contact.Notes)
);
var blocks = new JArray();
blocks.Add(new JObject
{
["name"] = "contact",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_contact",
["attributes"] = contactBlock
});
blocks.Add(new JObject
{
["name"] = "address",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_address",
["attributes"] = addressBlock
});
blocks.Add(new JObject
{
["name"] = "telephone",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_telephone",
["attributes"] = phoneBlock
});
blocks.Add(new JObject
{
["name"] = "email",
["resourceKey"] = "de.vertico.starface.addressbook.block.label_email",
["attributes"] = emailBlock
});
return new JObject
{
["id"] = contact.StarfaceId ?? "",
["blocks"] = new JArray
{
new JObject
{
["name"] = "contact",
["resourceKey"] = "contact",
["attributes"] = attrs
}
}
["blocks"] = blocks
};
}

View File

@ -15,30 +15,103 @@ namespace StarfaceOutlookSync.Services
private void Log(string message) => OnProgress?.Invoke(message);
/// <summary>
/// Findet einen passenden Kontakt in der Kandidatenliste.
/// Strenges Matching: Felder die auf einer Seite gefuellt sind muessen
/// auf der anderen auch gefuellt (und gleich) sein.
/// Ein leeres Feld auf einer Seite und ein gefuelltes auf der anderen
/// bedeutet: verschiedene Kontakte.
/// </summary>
private static UnifiedContact FindMatch(UnifiedContact contact, List<UnifiedContact> candidates)
{
// Erst E-Mail-Match
if (!string.IsNullOrEmpty(contact.Email))
{
var byEmail = candidates.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.Email) &&
c.Email.Equals(contact.Email, StringComparison.OrdinalIgnoreCase));
if (byEmail != null) return byEmail;
}
if (candidates == null || candidates.Count == 0) return null;
// Dann Name-Match
if (!string.IsNullOrEmpty(contact.FirstName) || !string.IsNullOrEmpty(contact.LastName))
foreach (var c in candidates)
{
var byName = candidates.FirstOrDefault(c =>
c.FirstName.Equals(contact.FirstName, StringComparison.OrdinalIgnoreCase) &&
c.LastName.Equals(contact.LastName, StringComparison.OrdinalIgnoreCase) &&
(!string.IsNullOrEmpty(c.FirstName) || !string.IsNullOrEmpty(c.LastName)));
if (byName != null) return byName;
if (IsMatch(contact, c))
return c;
}
return null;
}
private static bool IsMatch(UnifiedContact a, UnifiedContact b)
{
// Mindestens ein identifizierendes Feld muss vorhanden sein
bool hasName = !string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName);
bool hasEmail = !string.IsNullOrEmpty(a.Email);
bool hasPhone = !string.IsNullOrEmpty(a.PhoneWork) || !string.IsNullOrEmpty(a.PhoneMobile);
if (!hasName && !hasEmail && !hasPhone) return false;
// E-Mail: wenn auf beiden Seiten vorhanden, muss sie gleich sein
// Wenn nur auf einer Seite vorhanden -> kein Match
if (!FieldsCompatible(a.Email, b.Email)) return false;
// Name: wenn auf einer Seite vorhanden, muss er gleich sein
if (!FieldsCompatible(a.FirstName, b.FirstName)) return false;
if (!FieldsCompatible(a.LastName, b.LastName)) return false;
// Firma: wenn auf einer Seite vorhanden, muss sie gleich sein
// Leere Firma vs. gefuellte Firma = verschiedene Kontakte
if (!FieldsCompatible(a.Company, b.Company)) return false;
// Telefon/Fax: wenn auf einer Seite vorhanden, muss es gleich sein
if (!PhoneFieldsCompatible(a.PhoneWork, b.PhoneWork)) return false;
if (!PhoneFieldsCompatible(a.PhoneMobile, b.PhoneMobile)) return false;
if (!PhoneFieldsCompatible(a.PhoneHome, b.PhoneHome)) return false;
if (!PhoneFieldsCompatible(a.Fax, b.Fax)) return false;
// Mindestens ein starkes Match muss vorhanden sein
bool emailMatch = !string.IsNullOrEmpty(a.Email) && !string.IsNullOrEmpty(b.Email)
&& a.Email.Equals(b.Email, StringComparison.OrdinalIgnoreCase);
bool nameMatch = hasName
&& a.FirstName.Equals(b.FirstName, StringComparison.OrdinalIgnoreCase)
&& a.LastName.Equals(b.LastName, StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(a.FirstName) || !string.IsNullOrEmpty(a.LastName));
bool phoneMatch = (!string.IsNullOrEmpty(a.PhoneWork) && !string.IsNullOrEmpty(b.PhoneWork)
&& NormalizePhone(a.PhoneWork) == NormalizePhone(b.PhoneWork))
|| (!string.IsNullOrEmpty(a.Fax) && !string.IsNullOrEmpty(b.Fax)
&& NormalizePhone(a.Fax) == NormalizePhone(b.Fax));
bool companyMatch = !string.IsNullOrEmpty(a.Company) && !string.IsNullOrEmpty(b.Company)
&& a.Company.Equals(b.Company, StringComparison.OrdinalIgnoreCase);
// Email oder Name reicht. Telefon/Fax nur mit Firma zusammen.
return emailMatch || nameMatch || (phoneMatch && companyMatch) || (companyMatch && phoneMatch);
}
/// <summary>
/// Prueft ob zwei Felder kompatibel sind.
/// Beide leer = kompatibel. Beide gleich = kompatibel.
/// Eins leer, eins gefuellt = NICHT kompatibel (verschiedene Kontakte).
/// </summary>
private static bool FieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false; // Einer leer, anderer nicht
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
private static bool PhoneFieldsCompatible(string a, string b)
{
bool aEmpty = string.IsNullOrEmpty(a);
bool bEmpty = string.IsNullOrEmpty(b);
if (aEmpty && bEmpty) return true;
if (aEmpty != bEmpty) return false;
return NormalizePhone(a) == NormalizePhone(b);
}
private static string NormalizePhone(string phone)
{
if (string.IsNullOrEmpty(phone)) return "";
// Nur Ziffern und + behalten
return new string(phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
}
public async Task<SyncResult> SyncProfileAsync(SyncProfile profile)
{
var result = new SyncResult
@ -49,10 +122,10 @@ namespace StarfaceOutlookSync.Services
try
{
// Starface verbinden
Log("Verbinde mit Starface...");
using (var starface = new StarfaceApiClient(profile.StarfaceConnection))
{
starface.OnDebug += (msg) => Log(msg);
var loginOk = await starface.LoginAsync();
if (!loginOk)
{
@ -61,10 +134,6 @@ namespace StarfaceOutlookSync.Services
return result;
}
var mappings = _profileManager.GetMappings(profile.Id);
var mappingByOutlook = mappings.ToDictionary(m => m.OutlookEntryId, m => m);
var mappingByStarface = mappings.ToDictionary(m => m.StarfaceId, m => m);
// Kontakte laden
Log("Lade Outlook-Kontakte...");
var outlookContacts = _outlookService.GetContacts(profile.OutlookFolderPath);
@ -74,54 +143,192 @@ namespace StarfaceOutlookSync.Services
var starfaceContacts = await starface.GetContactsAsync(profile.StarfaceAddressBook);
Log($"{starfaceContacts.Count} Starface-Kontakte geladen");
// Outlook -> Starface
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.OutlookToStarface)
{
Log("Synchronisiere Outlook -> Starface...");
foreach (var oc in outlookContacts)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(oc.OutlookEntryId))
mappingByOutlook.TryGetValue(oc.OutlookEntryId, out existing);
// Bestehende Mappings laden
var mappings = _profileManager.GetMappings(profile.Id);
if (existing != null)
// Sets fuer schnellen Lookup
var mappingByOutlook = new Dictionary<string, SyncMapping>();
var mappingByStarface = new Dictionary<string, SyncMapping>();
foreach (var m in mappings)
{
var hash = oc.GetHash();
if (hash != existing.LastSyncHash)
{
if (await starface.UpdateContactAsync(existing.StarfaceId, oc, profile.StarfaceAddressBook))
{
existing.LastSyncHash = hash;
result.Updated++;
if (!string.IsNullOrEmpty(m.OutlookEntryId))
mappingByOutlook[m.OutlookEntryId] = m;
if (!string.IsNullOrEmpty(m.StarfaceId))
mappingByStarface[m.StarfaceId] = m;
}
// Tracking: welche Kontakte wurden bereits verarbeitet
var processedStarfaceIds = new HashSet<string>();
var processedOutlookIds = new HashSet<string>();
var newMappings = new List<SyncMapping>();
// ============================================
// Phase 1: Bestehende Mappings abgleichen
// ============================================
Log("Gleiche bestehende Zuordnungen ab...");
foreach (var mapping in mappings.ToList())
{
var oc = outlookContacts.FirstOrDefault(c => c.OutlookEntryId == mapping.OutlookEntryId);
var sc = starfaceContacts.FirstOrDefault(c => c.StarfaceId == mapping.StarfaceId);
if (oc != null) processedOutlookIds.Add(oc.OutlookEntryId);
if (sc != null) processedStarfaceIds.Add(sc.StarfaceId);
if (oc == null && sc == null)
{
// Beide Seiten geloescht -> Mapping entfernen
Log($" Mapping verwaist (beide geloescht), entferne");
continue;
}
if (oc == null && sc != null)
{
// Outlook-Kontakt nicht gefunden.
// Erst pruefen ob er vielleicht nur eine neue EntryID hat
var reMatch = FindMatch(sc, outlookContacts.Where(c =>
!processedOutlookIds.Contains(c.OutlookEntryId)).ToList());
if (reMatch != null)
{
// Kontakt existiert noch in Outlook, nur EntryID geaendert
Log($" EntryID geaendert, verknuepfe neu: {sc.DisplayName}");
mapping.OutlookEntryId = reMatch.OutlookEntryId;
processedOutlookIds.Add(reMatch.OutlookEntryId);
newMappings.Add(mapping);
continue;
}
// Wirklich geloescht -> in Starface auch loeschen
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
if (await starface.DeleteContactAsync(mapping.StarfaceId))
{
result.Updated++;
Log($" Geloescht (OL->SF): {sc.DisplayName}");
}
}
else
{
var match = FindMatch(oc, starfaceContacts);
if (match != null && !string.IsNullOrEmpty(match.StarfaceId))
newMappings.Add(mapping);
}
continue;
}
if (oc != null && sc == null)
{
// Starface-Kontakt nicht gefunden.
// Kann passieren wenn der Kontakt einem anderen Adressbuch gehoert.
// NICHT loeschen, nur Mapping entfernen - wird in Phase 2/3 neu verknuepft
Log($" Starface-Kontakt nicht in Liste (anderes Adressbuch?): {oc.DisplayName}");
// Mapping verwerfen, Outlook-Kontakt als unverarbeitet belassen
// damit er in Phase 2 neu zugeordnet oder erstellt werden kann
continue;
}
if (oc != null && sc != null)
{
// Beide vorhanden -> auf Aenderungen pruefen
var olHash = oc.GetHash();
var sfHash = sc.GetHash();
bool olChanged = olHash != mapping.LastSyncHash;
bool sfChanged = sfHash != mapping.LastSyncHash;
if (olChanged && !sfChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface))
{
// Outlook hat sich geaendert -> Starface updaten
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{
mapping.LastSyncHash = olHash;
result.Updated++;
Log($" Aktualisiert (OL->SF): {oc.DisplayName}");
}
}
else if (sfChanged && !olChanged && (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook))
{
// Starface hat sich geaendert -> Outlook updaten
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
{
mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Aktualisiert (SF->OL): {sc.DisplayName}");
}
}
else if (olChanged && sfChanged)
{
// Beide geaendert -> Konflikt, neuere gewinnt (Outlook bevorzugt)
if (profile.SyncDirection != SyncDirection.StarfaceToOutlook)
{
if (await starface.UpdateContactAsync(mapping.StarfaceId, oc, profile.StarfaceAddressBook))
{
mapping.LastSyncHash = olHash;
result.Updated++;
Log($" Konflikt (OL gewinnt): {oc.DisplayName}");
}
}
else
{
if (_outlookService.UpdateContact(mapping.OutlookEntryId, sc))
{
mapping.LastSyncHash = sfHash;
result.Updated++;
Log($" Konflikt (SF gewinnt): {sc.DisplayName}");
}
}
}
// Beide unveraendert -> nichts tun
}
newMappings.Add(mapping);
}
// ============================================
// Phase 2: Neue Outlook-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.OutlookToStarface)
{
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
if (unmappedOutlook.Count > 0)
Log($"Neue Outlook-Kontakte: {unmappedOutlook.Count}");
// Starface-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
foreach (var oc in unmappedOutlook)
{
try
{
// Duplikat-Check: existiert der Kontakt schon in der Starface?
var match = FindMatch(oc, unmappedStarface);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
if (await starface.UpdateContactAsync(match.StarfaceId, oc, profile.StarfaceAddressBook))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
StarfaceId = match.StarfaceId,
LastSyncHash = oc.GetHash()
});
processedStarfaceIds.Add(match.StarfaceId);
unmappedStarface.Remove(match);
result.Updated++;
Log($" Verknuepft (OL->SF): {oc.DisplayName}");
}
}
else
{
// Neu -> in Starface erstellen
Log($" Erstelle in Starface: {oc.DisplayName}");
var created = await starface.CreateContactAsync(oc, profile.StarfaceAddressBook);
if (created != null && !string.IsNullOrEmpty(created.StarfaceId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = oc.OutlookEntryId,
@ -129,66 +336,71 @@ namespace StarfaceOutlookSync.Services
LastSyncHash = oc.GetHash()
});
result.Created++;
Log($" Erstellt (OL->SF): {oc.DisplayName}");
}
else
{
Log($" FEHLER: Kontakt konnte nicht erstellt werden: {oc.DisplayName}");
result.Errors++;
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{oc.DisplayName}: {ex.Message}");
result.ErrorMessages.Add($"OL->SF {oc.DisplayName}: {ex.Message}");
}
}
}
// Starface -> Outlook
if (profile.SyncDirection == SyncDirection.Both ||
profile.SyncDirection == SyncDirection.StarfaceToOutlook)
// ============================================
// Phase 3: Neue Starface-Kontakte (ohne Mapping)
// ============================================
if (profile.SyncDirection == SyncDirection.Both || profile.SyncDirection == SyncDirection.StarfaceToOutlook)
{
Log("Synchronisiere Starface -> Outlook...");
foreach (var sc in starfaceContacts)
var unmappedStarface = starfaceContacts
.Where(c => !string.IsNullOrEmpty(c.StarfaceId) && !processedStarfaceIds.Contains(c.StarfaceId))
.ToList();
if (unmappedStarface.Count > 0)
Log($"Neue Starface-Kontakte: {unmappedStarface.Count}");
// Outlook-Kontakte die noch kein Mapping haben (fuer Duplikat-Check)
var unmappedOutlook = outlookContacts
.Where(c => !string.IsNullOrEmpty(c.OutlookEntryId) && !processedOutlookIds.Contains(c.OutlookEntryId))
.ToList();
foreach (var sc in unmappedStarface)
{
try
{
SyncMapping existing = null;
if (!string.IsNullOrEmpty(sc.StarfaceId))
mappingByStarface.TryGetValue(sc.StarfaceId, out existing);
if (existing != null)
{
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))
// Duplikat-Check: existiert der Kontakt schon in Outlook?
var match = FindMatch(sc, unmappedOutlook);
if (match != null)
{
// Existiert schon -> verknuepfen und updaten
if (_outlookService.UpdateContact(match.OutlookEntryId, sc))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = match.OutlookEntryId,
StarfaceId = sc.StarfaceId,
LastSyncHash = sc.GetHash()
});
processedOutlookIds.Add(match.OutlookEntryId);
unmappedOutlook.Remove(match);
result.Updated++;
Log($" Verknuepft (SF->OL): {sc.DisplayName}");
}
}
else
{
// Neu -> in Outlook erstellen
var created = _outlookService.CreateContact(sc, profile.OutlookFolderPath);
if (created != null && !string.IsNullOrEmpty(created.OutlookEntryId))
{
_profileManager.AddOrUpdateMapping(new SyncMapping
newMappings.Add(new SyncMapping
{
ProfileId = profile.Id,
OutlookEntryId = created.OutlookEntryId,
@ -196,22 +408,25 @@ namespace StarfaceOutlookSync.Services
LastSyncHash = sc.GetHash()
});
result.Created++;
}
Log($" Erstellt (SF->OL): {sc.DisplayName}");
}
}
}
catch (Exception ex)
{
result.Errors++;
result.ErrorMessages.Add($"{sc.DisplayName}: {ex.Message}");
result.ErrorMessages.Add($"SF->OL {sc.DisplayName}: {ex.Message}");
}
}
}
// Mappings speichern
_profileManager.SaveMappings(profile.Id, newMappings);
_profileManager.UpdateLastSync(profile.Id);
_profileManager.SaveMappings(profile.Id, mappings);
await starface.LogoutAsync();
Log("Synchronisation abgeschlossen!");
Log($"Fertig: {result.Created} erstellt, {result.Updated} aktualisiert, {result.Errors} Fehler");
}
}
catch (Exception ex)

View File

@ -7,9 +7,9 @@
<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>
<Version>0.0.0.23</Version>
<AssemblyVersion>0.0.0.23</AssemblyVersion>
<FileVersion>0.0.0.23</FileVersion>
<Description>Synchronisiert Outlook-Kontakte mit Starface Telefonanlage</Description>
<Copyright>Stefan Hacker - HackerSoft</Copyright>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
@ -19,7 +19,6 @@
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Office.Interop.Outlook" Version="15.0.4797.1004" />
</ItemGroup>
</Project>

View File

@ -27,7 +27,7 @@ namespace StarfaceOutlookSync.UI
var lblVersion = new Label
{
Text = "Version 0.0.0.5",
Text = "Version 0.0.0.23",
Left = 0, Top = 56, Width = 340, Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray

View File

@ -0,0 +1,108 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace StarfaceOutlookSync.UI
{
/// <summary>
/// Generiert das App-Icon programmatisch (Kontakt-Symbol mit Sync-Pfeilen).
/// Kein externes ICO-File noetig.
/// </summary>
public static class AppIcon
{
private static Icon _icon;
public static Icon GetIcon()
{
if (_icon != null) return _icon;
_icon = CreateIcon(32);
return _icon;
}
public static Icon GetSmallIcon()
{
return CreateIcon(16);
}
private static Icon CreateIcon(int size)
{
using (var bmp = new Bitmap(size, size))
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
if (size >= 32)
DrawIcon32(g);
else
DrawIcon16(g);
return Icon.FromHandle(bmp.GetHicon());
}
}
private static void DrawIcon32(Graphics g)
{
// Hintergrund: abgerundetes Quadrat
using (var bgBrush = new SolidBrush(Color.FromArgb(0, 120, 212)))
{
FillRoundedRect(g, bgBrush, 0, 0, 31, 31, 6);
}
// Person (Kopf)
using (var whiteBrush = new SolidBrush(Color.White))
{
g.FillEllipse(whiteBrush, 11, 4, 10, 10);
// Person (Koerper)
g.FillPie(whiteBrush, 6, 14, 20, 18, 180, 180);
}
// Sync-Pfeile unten rechts
using (var arrowPen = new Pen(Color.FromArgb(120, 255, 120), 2f))
{
arrowPen.StartCap = LineCap.Round;
arrowPen.EndCap = LineCap.ArrowAnchor;
// Pfeil rechts
g.DrawArc(arrowPen, 20, 22, 10, 8, 200, 140);
arrowPen.Color = Color.FromArgb(255, 200, 80);
// Pfeil links
g.DrawArc(arrowPen, 20, 22, 10, 8, 20, 140);
}
}
private static void DrawIcon16(Graphics g)
{
// Hintergrund
using (var bgBrush = new SolidBrush(Color.FromArgb(0, 120, 212)))
{
FillRoundedRect(g, bgBrush, 0, 0, 15, 15, 3);
}
// Person (vereinfacht)
using (var whiteBrush = new SolidBrush(Color.White))
{
g.FillEllipse(whiteBrush, 4, 1, 7, 7);
g.FillPie(whiteBrush, 2, 8, 12, 10, 180, 180);
}
// Kleiner Sync-Indikator
using (var dotBrush = new SolidBrush(Color.FromArgb(120, 255, 120)))
{
g.FillEllipse(dotBrush, 11, 11, 4, 4);
}
}
private static void FillRoundedRect(Graphics g, Brush brush, int x, int y, int w, int h, int r)
{
using (var path = new GraphicsPath())
{
path.AddArc(x, y, r * 2, r * 2, 180, 90);
path.AddArc(x + w - r * 2, y, r * 2, r * 2, 270, 90);
path.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 0, 90);
path.AddArc(x, y + h - r * 2, r * 2, r * 2, 90, 90);
path.CloseFigure();
g.FillPath(brush, path);
}
}
}
}

View File

@ -18,10 +18,11 @@ namespace StarfaceOutlookSync.UI
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private ListView _profileList;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo;
private Button _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo;
private StatusStrip _statusBar;
private ToolStripStatusLabel _statusLabel;
private Timer _autoSyncTimer;
private volatile bool _syncRunning = false;
public MainForm()
{
@ -30,14 +31,31 @@ namespace StarfaceOutlookSync.UI
SetupAutoSync();
RefreshProfileList();
// Minimiert starten falls in Einstellungen aktiviert
// Einstellungen laden und anwenden
var settings = UserSettings.Load();
settings.ApplyOutlookSecuritySetting();
if (settings.StartMinimized)
{
WindowState = FormWindowState.Minimized;
ShowInTaskbar = false;
Visible = false;
}
// Beim Start automatisch synchronisieren
if (settings.SyncOnStart)
{
_ = SyncAllProfiles();
}
}
private async Task SyncAllProfiles()
{
var profiles = _profileManager.GetProfiles().Where(p => p.Enabled).ToList();
foreach (var profile in profiles)
{
await RunSync(profile);
}
}
protected override void SetVisibleCore(bool value)
@ -58,10 +76,11 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Starface Kontakt-Sync";
Size = new Size(620, 450);
MinimumSize = new Size(500, 350);
Size = new Size(830, 450);
MinimumSize = new Size(830, 350);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Segoe UI", 9);
Icon = AppIcon.GetIcon();
// Profil-Liste
_profileList = new ListView
@ -97,16 +116,19 @@ namespace StarfaceOutlookSync.UI
_btnDelete = new Button { Text = "Loeschen", Width = 80, Height = 30 };
_btnDelete.Click += (s, e) => DeleteProfile();
_btnSync = new Button { Text = "Jetzt synchronisieren", Width = 150, Height = 30 };
_btnSync = new Button { Text = "Synchronisieren", Width = 110, Height = 30 };
_btnSync.Click += async (s, e) => await SyncSelectedProfile();
_btnSettings = new Button { Text = "Einstellungen", Width = 100, Height = 30 };
_btnReset = new Button { Text = "Sync Reset", Width = 80, Height = 30 };
_btnReset.Click += (s, e) => ResetSync();
_btnSettings = new Button { Text = "Einstellungen", Width = 95, Height = 30 };
_btnSettings.Click += (s, e) => ShowSettings();
_btnInfo = new Button { Text = "Info", Width = 50, Height = 30 };
_btnInfo.Click += (s, e) => ShowAbout();
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnSettings, _btnInfo });
buttonPanel.Controls.AddRange(new Control[] { _btnNew, _btnEdit, _btnDelete, _btnSync, _btnReset, _btnSettings, _btnInfo });
// Statusbar
_statusBar = new StatusStrip();
@ -121,10 +143,26 @@ namespace StarfaceOutlookSync.UI
private void SetupTrayIcon()
{
_trayMenu = new ContextMenuStrip();
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = AppIcon.GetSmallIcon(),
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
UpdateTrayMenu();
}
private void UpdateTrayMenu()
{
_trayMenu.Items.Clear();
_trayMenu.Items.Add("Oeffnen", null, (s, e) => ShowMainWindow());
_trayMenu.Items.Add("-");
// Schnell-Sync fuer jedes Profil
var profiles = _profileManager.GetProfiles();
foreach (var p in profiles.Where(p => p.Enabled))
{
@ -138,17 +176,8 @@ namespace StarfaceOutlookSync.UI
if (profiles.Any(p => p.Enabled))
_trayMenu.Items.Add("-");
_trayMenu.Items.Add("Ueber", null, (s, e) => ShowAbout());
_trayMenu.Items.Add("Beenden", null, (s, e) => ExitApplication());
_trayIcon = new NotifyIcon
{
Text = "Starface Kontakt-Sync",
Icon = SystemIcons.Application, // Placeholder, wird durch eigenes Icon ersetzt
ContextMenuStrip = _trayMenu,
Visible = true
};
_trayIcon.DoubleClick += (s, e) => ShowMainWindow();
}
private void SetupAutoSync()
@ -216,7 +245,7 @@ namespace StarfaceOutlookSync.UI
}
// Tray-Menu aktualisieren
SetupTrayIcon();
UpdateTrayMenu();
}
private void NewProfile()
@ -255,6 +284,36 @@ namespace StarfaceOutlookSync.UI
}
}
private void ResetSync()
{
if (_profileList.SelectedItems.Count == 0)
{
MessageBox.Show("Bitte ein Profil auswaehlen.", "Sync Reset",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var profile = _profileList.SelectedItems[0].Tag as SyncProfile;
if (profile == null) return;
var msg = $"Sync-Zuordnungen fuer '{profile.Name}' zuruecksetzen?\n\n" +
"Alle Kontakt-Verknuepfungen werden geloescht.\n" +
"Beim naechsten Sync werden die Kontakte neu abgeglichen.\n" +
"Es werden keine Kontakte geloescht.";
if (MessageBox.Show(msg, "Sync Reset",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
{
_profileManager.SaveMappings(profile.Id, new List<SyncMapping>());
// LastSync auch zuruecksetzen
profile.LastSync = "";
_profileManager.UpdateProfile(profile);
RefreshProfileList();
MessageBox.Show("Sync-Zuordnungen wurden zurueckgesetzt.",
"Sync Reset", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private Task SyncSelectedProfile()
{
if (_profileList.SelectedItems.Count == 0)
@ -278,6 +337,13 @@ namespace StarfaceOutlookSync.UI
private async Task RunSync(SyncProfile profile)
{
if (_syncRunning)
{
SetStatus("Sync laeuft bereits, bitte warten...");
return;
}
_syncRunning = true;
try
{
SetStatus($"Synchronisiere '{profile.Name}'...");
@ -300,6 +366,10 @@ namespace StarfaceOutlookSync.UI
ex.Message, ToolTipIcon.Error);
SetStatus($"Fehler: {ex.Message}");
}
finally
{
_syncRunning = false;
}
}
private void SetStatus(string text)

View File

@ -333,9 +333,23 @@ namespace StarfaceOutlookSync.UI
};
if (_isNew)
{
_pm.AddProfile(profile);
}
else
{
// Wenn Adressbuch gewechselt wurde, Mappings zuruecksetzen
if (_existingProfile.StarfaceAddressBook?.TagId != profile.StarfaceAddressBook?.TagId
|| _existingProfile.StarfaceAddressBook?.Type != profile.StarfaceAddressBook?.Type)
{
_pm.SaveMappings(profile.Id, new List<SyncMapping>());
profile.LastSync = "";
MessageBox.Show(
"Adressbuch wurde geaendert.\nSync-Zuordnungen wurden automatisch zurueckgesetzt.",
"Adressbuch geaendert", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
_pm.UpdateProfile(profile);
}
DialogResult = DialogResult.OK;
Close();

View File

@ -6,7 +6,7 @@ namespace StarfaceOutlookSync.UI
{
public class SettingsForm : Form
{
private CheckBox _chkStartMinimized;
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
private Button _btnSave, _btnCancel;
private readonly UserSettings _settings;
@ -19,7 +19,7 @@ namespace StarfaceOutlookSync.UI
private void InitializeComponent()
{
Text = "Einstellungen";
Size = new Size(350, 180);
Size = new Size(380, 250);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
@ -33,20 +33,47 @@ namespace StarfaceOutlookSync.UI
Checked = _settings.StartMinimized
};
_chkSyncOnStart = new CheckBox
{
Text = "Beim Start automatisch synchronisieren",
Left = 20, Top = 52, AutoSize = true,
Checked = _settings.SyncOnStart
};
_chkAutoAcceptOutlook = new CheckBox
{
Text = "Outlook-Sicherheitsabfrage automatisch erlauben",
Left = 20, Top = 80, AutoSize = true,
Checked = _settings.AutoAcceptOutlookPrompt
};
var hintText = "Hinweis: Outlook muss nach Aenderung neu gestartet werden.";
if (UserSettings.IsOutlookSecurityLockedByPolicy())
hintText += "\nAuf Domaenen-PCs: App einmalig als Admin starten!";
var lblHint = new Label
{
Text = hintText,
Left = 38, Top = 102, Width = 310, Height = 36,
ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray,
Font = new Font("Segoe UI", 8)
};
_btnSave = new Button
{
Text = "Speichern", Left = 80, Top = 100, Width = 85, Height = 28,
Text = "Speichern", Left = 95, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.None
};
_btnSave.Click += (s, e) => Save();
_btnCancel = new Button
{
Text = "Abbrechen", Left = 174, Top = 100, Width = 85, Height = 28,
Text = "Abbrechen", Left = 189, Top = 170, Width = 85, Height = 28,
DialogResult = DialogResult.Cancel
};
Controls.AddRange(new Control[] { _chkStartMinimized, _btnSave, _btnCancel });
Size = new Size(380, 260);
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel });
AcceptButton = _btnSave;
CancelButton = _btnCancel;
}
@ -54,6 +81,8 @@ namespace StarfaceOutlookSync.UI
private void Save()
{
_settings.StartMinimized = _chkStartMinimized.Checked;
_settings.SyncOnStart = _chkSyncOnStart.Checked;
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
_settings.Save();
DialogResult = DialogResult.OK;
Close();

View File

@ -41,7 +41,8 @@ namespace StarfaceOutlookSync.UI
_progressBar = new ProgressBar
{
Left = 12, Top = 38, Width = 460, Height = 22,
Style = ProgressBarStyle.Marquee
Style = ProgressBarStyle.Blocks,
Value = 0
};
_txtLog = new TextBox