diff --git a/SharepointToolbox/App.xaml b/SharepointToolbox/App.xaml
index 1cfc57a..b937526 100644
--- a/SharepointToolbox/App.xaml
+++ b/SharepointToolbox/App.xaml
@@ -3,6 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharepointToolbox">
-
+
diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs
index 3bfe562..0746383 100644
--- a/SharepointToolbox/App.xaml.cs
+++ b/SharepointToolbox/App.xaml.cs
@@ -1,13 +1,17 @@
+using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Serilog;
+using SharepointToolbox.Infrastructure.Auth;
+using SharepointToolbox.Infrastructure.Logging;
+using SharepointToolbox.Services;
+using SharepointToolbox.ViewModels;
+using SharepointToolbox.ViewModels.Tabs;
using System.Windows;
namespace SharepointToolbox;
-///
-/// Interaction logic for App.xaml
-///
public partial class App : Application
{
[STAThread]
@@ -16,7 +20,7 @@ public partial class App : Application
using IHost host = Host.CreateDefaultBuilder(args)
.UseSerilog((ctx, cfg) => cfg
.WriteTo.File(
- System.IO.Path.Combine(
+ Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
@@ -27,16 +31,49 @@ public partial class App : Application
host.Start();
App app = new();
app.InitializeComponent();
- app.MainWindow = host.Services.GetRequiredService();
+
+ var mainWindow = host.Services.GetRequiredService();
+
+ // Wire LogPanelSink now that we have the RichTextBox
+ Log.Logger = new LoggerConfiguration()
+ .WriteTo.File(
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "SharepointToolbox", "logs", "app-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 30)
+ .WriteTo.Sink(new LogPanelSink(mainWindow.GetLogPanel()))
+ .CreateLogger();
+
+ // Global exception handlers
+ app.DispatcherUnhandledException += (s, e) =>
+ {
+ Log.Fatal(e.Exception, "Unhandled UI exception");
+ MessageBox.Show(
+ $"A fatal error occurred:\n{e.Exception.Message}\n\nCheck log for details.",
+ "Fatal Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ e.Handled = true;
+ };
+ TaskScheduler.UnobservedTaskException += (s, e) =>
+ {
+ Log.Fatal(e.Exception, "Unobserved task exception");
+ e.SetObserved();
+ };
+
+ app.MainWindow = mainWindow;
app.MainWindow.Visibility = Visibility.Visible;
app.Run();
}
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
{
- // Placeholder — services registered in subsequent plans
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddTransient();
+ services.AddTransient();
services.AddSingleton();
- // LogPanelSink registered in plan 01-06 after MainWindow is created
- // (requires RichTextBox reference from MainWindow)
}
}
diff --git a/SharepointToolbox/MainWindow.xaml b/SharepointToolbox/MainWindow.xaml
index 2dbf194..934d97c 100644
--- a/SharepointToolbox/MainWindow.xaml
+++ b/SharepointToolbox/MainWindow.xaml
@@ -1,12 +1,71 @@
-
-
+ Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
+ MinWidth="900" MinHeight="600" Height="700" Width="1100">
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SharepointToolbox/MainWindow.xaml.cs b/SharepointToolbox/MainWindow.xaml.cs
index bce9213..0e8c340 100644
--- a/SharepointToolbox/MainWindow.xaml.cs
+++ b/SharepointToolbox/MainWindow.xaml.cs
@@ -1,23 +1,26 @@
-using System.Text;
using System.Windows;
using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
+using SharepointToolbox.ViewModels;
namespace SharepointToolbox;
-///
-/// Interaction logic for MainWindow.xaml
-///
public partial class MainWindow : Window
{
- public MainWindow()
+ private readonly MainWindowViewModel _viewModel;
+
+ public MainWindow(MainWindowViewModel viewModel)
{
InitializeComponent();
+ _viewModel = viewModel;
+ DataContext = viewModel;
+ Loaded += OnLoaded;
}
-}
\ No newline at end of file
+
+ private async void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ await _viewModel.LoadProfilesAsync();
+ }
+
+ // Expose the LogPanel RichTextBox (generated by x:Name="LogPanel") for LogPanelSink wiring
+ public RichTextBox GetLogPanel() => LogPanel;
+}
diff --git a/SharepointToolbox/ViewModels/MainWindowViewModel.cs b/SharepointToolbox/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..77b8097
--- /dev/null
+++ b/SharepointToolbox/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,127 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Extensions.Logging;
+using SharepointToolbox.Core.Messages;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.ViewModels;
+
+public partial class MainWindowViewModel : ObservableRecipient
+{
+ private readonly ProfileService _profileService;
+ private readonly SessionManager _sessionManager;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private TenantProfile? _selectedProfile;
+
+ [ObservableProperty]
+ private string _connectionStatus = "Not connected";
+
+ [ObservableProperty]
+ private string _progressStatus = string.Empty;
+
+ [ObservableProperty]
+ private int _progressPercentage;
+
+ public ObservableCollection TenantProfiles { get; } = new();
+
+ public IAsyncRelayCommand ConnectCommand { get; }
+ public IAsyncRelayCommand ClearSessionCommand { get; }
+ public RelayCommand ManageProfilesCommand { get; }
+
+ public MainWindowViewModel(
+ ProfileService profileService,
+ SessionManager sessionManager,
+ ILogger logger)
+ {
+ _profileService = profileService;
+ _sessionManager = sessionManager;
+ _logger = logger;
+
+ ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
+ ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
+ ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
+
+ IsActive = true;
+ }
+
+ protected override void OnActivated()
+ {
+ base.OnActivated();
+ Messenger.Register(this, (r, m) =>
+ {
+ var vm = (MainWindowViewModel)r;
+ vm.ProgressStatus = m.Value.Message;
+ vm.ProgressPercentage = m.Value.Total > 0
+ ? (int)(100.0 * m.Value.Current / m.Value.Total)
+ : 0;
+ });
+ }
+
+ partial void OnSelectedProfileChanged(TenantProfile? value)
+ {
+ if (value != null)
+ {
+ WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
+ }
+ ConnectCommand.NotifyCanExecuteChanged();
+ ClearSessionCommand.NotifyCanExecuteChanged();
+ }
+
+ public async Task LoadProfilesAsync()
+ {
+ try
+ {
+ var profiles = await _profileService.GetProfilesAsync();
+ TenantProfiles.Clear();
+ foreach (var profile in profiles)
+ TenantProfiles.Add(profile);
+
+ if (TenantProfiles.Count > 0)
+ SelectedProfile = TenantProfiles[0];
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load tenant profiles.");
+ }
+ }
+
+ private async Task ConnectAsync()
+ {
+ if (SelectedProfile == null) return;
+ try
+ {
+ ConnectionStatus = "Connecting...";
+ await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
+ ConnectionStatus = SelectedProfile.Name;
+ }
+ catch (Exception ex)
+ {
+ ConnectionStatus = "Connection failed";
+ _logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
+ }
+ }
+
+ private async Task ClearSessionAsync()
+ {
+ if (SelectedProfile == null) return;
+ try
+ {
+ await _sessionManager.ClearSessionAsync(SelectedProfile.TenantUrl);
+ ConnectionStatus = "Not connected";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to clear session for {TenantUrl}.", SelectedProfile.TenantUrl);
+ }
+ }
+
+ private void OpenProfileManagement()
+ {
+ // Profile management dialog opened by View layer (plan 01-07)
+ }
+}
diff --git a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
new file mode 100644
index 0000000..b4e7584
--- /dev/null
+++ b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
@@ -0,0 +1,125 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.ViewModels;
+
+public partial class ProfileManagementViewModel : ObservableObject
+{
+ private readonly ProfileService _profileService;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private TenantProfile? _selectedProfile;
+
+ [ObservableProperty]
+ private string _newName = string.Empty;
+
+ [ObservableProperty]
+ private string _newTenantUrl = string.Empty;
+
+ [ObservableProperty]
+ private string _newClientId = string.Empty;
+
+ [ObservableProperty]
+ private string _validationMessage = string.Empty;
+
+ public ObservableCollection Profiles { get; } = new();
+
+ public IAsyncRelayCommand AddCommand { get; }
+ public IAsyncRelayCommand RenameCommand { get; }
+ public IAsyncRelayCommand DeleteCommand { get; }
+
+ public ProfileManagementViewModel(ProfileService profileService, ILogger logger)
+ {
+ _profileService = profileService;
+ _logger = logger;
+
+ AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
+ RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
+ DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
+ }
+
+ public async Task LoadAsync()
+ {
+ try
+ {
+ var profiles = await _profileService.GetProfilesAsync();
+ Profiles.Clear();
+ foreach (var p in profiles)
+ Profiles.Add(p);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load profiles.");
+ }
+ }
+
+ private bool CanAdd()
+ {
+ if (string.IsNullOrWhiteSpace(NewName)) return false;
+ if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
+ if (string.IsNullOrWhiteSpace(NewClientId)) return false;
+ return true;
+ }
+
+ private async Task AddAsync()
+ {
+ if (!CanAdd()) return;
+ try
+ {
+ var profile = new TenantProfile
+ {
+ Name = NewName.Trim(),
+ TenantUrl = NewTenantUrl.Trim(),
+ ClientId = NewClientId.Trim()
+ };
+ await _profileService.AddProfileAsync(profile);
+ Profiles.Add(profile);
+ NewName = string.Empty;
+ NewTenantUrl = string.Empty;
+ NewClientId = string.Empty;
+ ValidationMessage = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ ValidationMessage = ex.Message;
+ _logger.LogError(ex, "Failed to add profile.");
+ }
+ }
+
+ private async Task RenameAsync()
+ {
+ if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
+ try
+ {
+ await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
+ SelectedProfile.Name = NewName.Trim();
+ NewName = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ ValidationMessage = ex.Message;
+ _logger.LogError(ex, "Failed to rename profile.");
+ }
+ }
+
+ private async Task DeleteAsync()
+ {
+ if (SelectedProfile == null) return;
+ try
+ {
+ await _profileService.DeleteProfileAsync(SelectedProfile.Name);
+ Profiles.Remove(SelectedProfile);
+ SelectedProfile = null;
+ }
+ catch (Exception ex)
+ {
+ ValidationMessage = ex.Message;
+ _logger.LogError(ex, "Failed to delete profile.");
+ }
+ }
+}
diff --git a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
new file mode 100644
index 0000000..350350e
--- /dev/null
+++ b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
@@ -0,0 +1,94 @@
+using System.Globalization;
+using Microsoft.Win32;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Localization;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.ViewModels.Tabs;
+
+public partial class SettingsViewModel : FeatureViewModelBase
+{
+ private readonly SettingsService _settingsService;
+
+ private string _selectedLanguage = "en";
+ public string SelectedLanguage
+ {
+ get => _selectedLanguage;
+ set
+ {
+ if (_selectedLanguage == value) return;
+ _selectedLanguage = value;
+ OnPropertyChanged();
+ _ = ApplyLanguageAsync(value);
+ }
+ }
+
+ private string _dataFolder = string.Empty;
+ public string DataFolder
+ {
+ get => _dataFolder;
+ set
+ {
+ if (_dataFolder == value) return;
+ _dataFolder = value;
+ OnPropertyChanged();
+ _ = _settingsService.SetDataFolderAsync(value);
+ }
+ }
+
+ public RelayCommand BrowseFolderCommand { get; }
+
+ public SettingsViewModel(SettingsService settingsService, ILogger logger)
+ : base(logger)
+ {
+ _settingsService = settingsService;
+ BrowseFolderCommand = new RelayCommand(BrowseFolder);
+ }
+
+ public async Task LoadAsync()
+ {
+ var settings = await _settingsService.GetSettingsAsync();
+ _selectedLanguage = settings.Lang;
+ _dataFolder = settings.DataFolder;
+ OnPropertyChanged(nameof(SelectedLanguage));
+ OnPropertyChanged(nameof(DataFolder));
+ }
+
+ private async Task ApplyLanguageAsync(string code)
+ {
+ try
+ {
+ TranslationSource.Instance.CurrentCulture = new CultureInfo(code);
+ await _settingsService.SetLanguageAsync(code);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = ex.Message;
+ }
+ }
+
+ private void BrowseFolder()
+ {
+ // OpenFolderDialog is available in .NET 8+ via Microsoft.Win32
+ var dialog = new OpenFolderDialog
+ {
+ Title = "Select data output folder",
+ InitialDirectory = string.IsNullOrEmpty(_dataFolder)
+ ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
+ : _dataFolder
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ DataFolder = dialog.FolderName;
+ }
+ }
+
+ protected override Task RunOperationAsync(CancellationToken ct, IProgress progress)
+ {
+ // Settings tab has no long-running operation
+ throw new NotSupportedException("Settings tab does not have a run operation.");
+ }
+}
diff --git a/SharepointToolbox/Views/Controls/FeatureTabBase.xaml b/SharepointToolbox/Views/Controls/FeatureTabBase.xaml
new file mode 100644
index 0000000..16780e2
--- /dev/null
+++ b/SharepointToolbox/Views/Controls/FeatureTabBase.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SharepointToolbox/Views/Controls/FeatureTabBase.xaml.cs b/SharepointToolbox/Views/Controls/FeatureTabBase.xaml.cs
new file mode 100644
index 0000000..39990c0
--- /dev/null
+++ b/SharepointToolbox/Views/Controls/FeatureTabBase.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace SharepointToolbox.Views.Controls;
+
+public partial class FeatureTabBase : UserControl
+{
+ public FeatureTabBase()
+ {
+ InitializeComponent();
+ }
+}