From 6d7560e5d7581605a5349881f5fc91bf8f7860f7 Mon Sep 17 00:00:00 2001 From: "Jarvis (OpenClaw Agent)" Date: Tue, 1 Sep 2026 14:27:29 +0200 Subject: [PATCH 1/7] refactor: serialize OneNote COM calls on STA worker --- CLAUDE.md | 26 +-- MainWindow.xaml.cs | 48 ++--- Services/OneNoteComWorker.cs | 220 +++++++++++++++++++++ Services/OneNoteService.cs | 363 ++++++++++------------------------- 4 files changed, 353 insertions(+), 304 deletions(-) create mode 100644 Services/OneNoteComWorker.cs diff --git a/CLAUDE.md b/CLAUDE.md index 661ad2a..ee14a65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,10 @@ Single-project WPF app, no subprocesses: ``` MainWindow.xaml / .xaml.cs (UI + code-behind) - ↕ direct method calls -Services/OneNoteService.cs (OneNote COM API wrapper) + ↕ async calls +Services/OneNoteComWorker.cs (serialized STA thread + message pump) + ↕ synchronous calls on the owning STA thread +Services/OneNoteService.cs (low-level OneNote COM API wrapper) ↕ COM API OneNote Desktop ``` @@ -32,8 +34,8 @@ OneNoteExporterC#/ │ └── Models.cs NotebookInfo, ExportResult, VersionInfo, │ BackupAvailability, NotebookViewModel ├── Services/ -│ └── OneNoteService.cs COM wrapper: GetNotebooks, ExportNotebook, -│ ExportAllNotebooks, GetVersionInfo, Dispose +│ ├── OneNoteComWorker.cs Dedicated STA owner, serialized async facade +│ └── OneNoteService.cs Low-level hierarchy and publish COM calls └── Helpers/ └── FileHelper.cs Local backup path, CopyLocalBackup, OpenFolder, GetDefaultDownloadsPath @@ -60,16 +62,18 @@ dotnet publish -c Release -r win-x64 --self-contained true - **Do NOT add `true`** – causes namespace conflict with the COM `Application` class ### Threading Model -- `OneNoteService` methods are synchronous and blocking (COM calls + polling with `Thread.Sleep`) -- Always call service methods via `Task.Run(...)` from the UI layer +- `OneNoteComWorker` owns `OneNoteService` and its COM object on one dedicated STA thread +- All OneNote COM calls are dispatched through that thread's WPF `Dispatcher` message pump +- Public worker methods are async and serialized; do not wrap them in `Task.Run(...)` +- Export file polling runs asynchronously outside the STA thread while the serialization gate remains held - Use `IProgress` for progress reporting (marshals back to UI thread automatically via `Progress`) -- Use `CancellationToken` for cooperative cancellation; `ct.WaitHandle.WaitOne(ms)` replaces `Thread.Sleep` in polling loops for cancellation-aware waits +- Use `CancellationToken` for cooperative cancellation and `Task.Delay(..., ct)` in polling loops ### Export Process -1. `OneNoteService` is initialized at window load via `Task.Run(() => new OneNoteService())` -2. `GetNotebooks()` retrieves all notebooks via `GetHierarchy()` XML parse -3. `ExportNotebook()` calls `OpenHierarchy()` then `Publish()` with the appropriate `PublishFormat` enum -4. After `Publish()` returns (immediately), the service polls the output file until size is stable for 10 seconds (file write monitoring) +1. `OneNoteComWorker.CreateAsync()` starts the STA thread and creates `OneNoteService` there +2. `GetNotebooksAsync()` retrieves all notebooks via `GetHierarchy()` XML parse on the worker +3. `ExportNotebookAsync()` dispatches `OpenHierarchy()` and `Publish()` to the STA worker +4. After `Publish()` returns, async file polling waits until size is stable for 10 seconds while the STA message pump stays responsive 5. Timeout: 20 min for local notebooks, 30 min for cloud (SharePoint/OneDrive) ### Export Formats diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 0dcb399..e8e726e 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -16,7 +16,7 @@ public partial class MainWindow : Window { // ── State ──────────────────────────────────────────────────────────────── - private OneNoteService? _service; + private OneNoteComWorker? _service; private CancellationTokenSource? _exportCts; private bool _exportInProgress = false; @@ -50,8 +50,8 @@ private async Task InitOneNoteServiceAsync() try { - _service = await Task.Run(() => new OneNoteService()); - var info = await Task.Run(() => _service.GetVersionInfo()); + _service = await OneNoteComWorker.CreateAsync(); + var info = await _service.GetVersionInfoAsync(); if (info.OneNoteInstalled) SetVersionBadge($"✓ {info.OneNoteVersion}", StatusKind.Success); @@ -77,7 +77,7 @@ private async Task LoadNotebooksAsync() if (_service == null) throw new InvalidOperationException("OneNote Helper is not available."); - var list = await Task.Run(() => _service.GetNotebooks()); + var list = await _service.GetNotebooksAsync(); ShowNotebookOverlay(OverlayKind.None); @@ -313,7 +313,7 @@ private async Task LoadSectionsAsync(NotebookViewModel nb) try { - var sections = await Task.Run(() => _service.GetSections(nb.Id)); + var sections = await _service.GetSectionsAsync(nb.Id); nb.Sections.Clear(); foreach (var s in sections) @@ -401,7 +401,7 @@ private void CancelButton_Click(object sender, RoutedEventArgs e) if (!_exportInProgress) return; var confirm = MessageBox.Show( - "Do you really want to cancel the export?\n\nWarning: This will terminate OneNote and OneNoteHelper processes!", + "Do you really want to cancel the export?\n\nThe current export will stop without closing OneNote.", "Cancel Export", MessageBoxButton.YesNo, MessageBoxImage.Warning); @@ -410,11 +410,7 @@ private void CancelButton_Click(object sender, RoutedEventArgs e) ShowStatus("Cancelling export...", StatusKind.Warning); - // Cancel via token _exportCts?.Cancel(); - - // Fallback: force-kill processes (mirrors Go CancelExport behaviour) - KillProcess("ONENOTE.EXE"); } // ── Export implementations ─────────────────────────────────────────────── @@ -480,8 +476,8 @@ private async Task RunComExportAsync( try { - var result = await Task.Run( - () => _service.ExportNotebook(nb.Id, destPath, format, progress, ct), ct); + var result = await _service.ExportNotebookAsync( + nb.Id, destPath, format, progress, ct); if (result.Success) { successCount++; messages.Add($"✓ {nb.Name}"); } else { failCount++; messages.Add($"✗ {nb.Name}: {result.Message}"); } @@ -515,8 +511,8 @@ private async Task RunComExportAsync( try { - var result = await Task.Run( - () => _service.ExportSection(sec.Info, destPath, format, progress, ct), ct); + var result = await _service.ExportSectionAsync( + sec.Info, destPath, format, progress, ct); if (result.Success) { successCount++; messages.Add($"✓ {nb.Name} / {sec.Name}"); } else { failCount++; messages.Add($"✗ {nb.Name} / {sec.Name}: {result.Message}"); } @@ -548,11 +544,11 @@ private async Task RunComExportAsync( // ── Utilities ──────────────────────────────────────────────────────────── - private static void StartDialogWatcher(CancellationToken ct) + private void StartDialogWatcher(CancellationToken ct) { _ = Task.Run(() => { - while (!ct.IsCancellationRequested) + while (!ct.IsCancellationRequested && _exportInProgress) { try { @@ -586,24 +582,4 @@ private static void StartDialogWatcher(CancellationToken ct) }); } - private static void KillProcess(string processName) - { - try - { - var psi = new ProcessStartInfo - { - FileName = "taskkill", - Arguments = $"/F /IM {processName}", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true - }; - using var p = Process.Start(psi); - p?.WaitForExit(5000); - } - catch (Exception ex) - { - Debug.WriteLine($"KillProcess({processName}): {ex.Message}"); - } - } } diff --git a/Services/OneNoteComWorker.cs b/Services/OneNoteComWorker.cs new file mode 100644 index 0000000..d9be8b1 --- /dev/null +++ b/Services/OneNoteComWorker.cs @@ -0,0 +1,220 @@ +using System.Windows.Threading; +using OneNoteExporter.Models; + +namespace OneNoteExporter.Services; + +/// +/// Owns the OneNote COM service on a dedicated STA thread. All operations are +/// dispatched through the thread's message pump and therefore run serially. +/// +public sealed class OneNoteComWorker : IDisposable +{ + private readonly Thread _thread; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _stopped = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private Dispatcher? _dispatcher; + private OneNoteService? _service; + private int _disposeState; + + private OneNoteComWorker() + { + _thread = new Thread(RunMessageLoop) + { + IsBackground = true, + Name = "OneNote COM STA Worker" + }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + } + + public static async Task CreateAsync() + { + var worker = new OneNoteComWorker(); + + try + { + await worker._started.Task.ConfigureAwait(false); + return worker; + } + catch + { + worker.Dispose(); + throw; + } + } + + public Task GetVersionInfoAsync(CancellationToken ct = default) + => RunSerializedAsync( + () => InvokeAsync(service => service.GetVersionInfo(), ct), ct); + + public Task> GetNotebooksAsync(CancellationToken ct = default) + => RunSerializedAsync( + () => InvokeAsync(service => service.GetNotebooks(), ct), ct); + + public Task> GetSectionsAsync( + string notebookId, + CancellationToken ct = default) + => RunSerializedAsync( + () => InvokeAsync(service => service.GetSections(notebookId), ct), ct); + + public Task ExportNotebookAsync( + string notebookId, + string destinationPath, + string exportFormat = "onepkg", + IProgress? progress = null, + CancellationToken ct = default) + => RunSerializedAsync( + () => ExportAsync( + service => service.BeginNotebookExport( + notebookId, destinationPath, exportFormat, progress), + progress, + ct), + ct); + + public Task ExportSectionAsync( + SectionInfo section, + string destinationPath, + string exportFormat = "onepkg", + IProgress? progress = null, + CancellationToken ct = default) + => RunSerializedAsync( + () => ExportAsync( + service => service.BeginSectionExport( + section, destinationPath, exportFormat, progress), + progress, + ct), + ct); + + private async Task ExportAsync( + Func beginExport, + IProgress? progress, + CancellationToken ct) + { + try + { + var pending = await InvokeAsync(beginExport, ct).ConfigureAwait(false); + return await OneNoteService.WaitForFileAsync( + pending.FullPath, pending.IsCloud, progress, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OneNoteService.CreateExportFailure(ex); + } + } + + private async Task RunSerializedAsync( + Func> operation, + CancellationToken ct) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeState) != 0, + this); + + await _operationGate.WaitAsync(ct).ConfigureAwait(false); + + try + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeState) != 0, + this); + return await operation().ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + private async Task InvokeAsync( + Func operation, + CancellationToken ct) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeState) != 0, + this); + + await _started.Task.ConfigureAwait(false); + + var dispatcher = _dispatcher + ?? throw new InvalidOperationException("The OneNote COM worker did not start."); + + var dispatcherOperation = dispatcher.InvokeAsync( + () => + { + ct.ThrowIfCancellationRequested(); + return operation(_service + ?? throw new InvalidOperationException("OneNote is not initialized.")); + }, + DispatcherPriority.Normal, + ct); + + return await dispatcherOperation.Task.ConfigureAwait(false); + } + + private void RunMessageLoop() + { + _dispatcher = Dispatcher.CurrentDispatcher; + + try + { + _service = new OneNoteService(); + + if (Volatile.Read(ref _disposeState) != 0) + { + _started.TrySetCanceled(); + return; + } + + _started.TrySetResult(); + Dispatcher.Run(); + } + catch (OperationCanceledException) when (Volatile.Read(ref _disposeState) != 0) + { + _started.TrySetCanceled(); + } + catch (Exception ex) + { + _started.TrySetException(ex); + } + finally + { + _service?.Dispose(); + _service = null; + _stopped.TrySetResult(); + } + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + { + _stopped.Task.GetAwaiter().GetResult(); + return; + } + + _operationGate.Wait(); + + try + { + var dispatcher = _dispatcher; + if (dispatcher != null && !dispatcher.HasShutdownStarted) + dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); + + if (Thread.CurrentThread != _thread) + _stopped.Task.GetAwaiter().GetResult(); + } + finally + { + _operationGate.Release(); + } + } +} diff --git a/Services/OneNoteService.cs b/Services/OneNoteService.cs index 8c86afc..e852fb3 100644 --- a/Services/OneNoteService.cs +++ b/Services/OneNoteService.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Xml.Linq; @@ -7,23 +6,19 @@ namespace OneNoteExporter.Services; +internal sealed record PendingExport(string FullPath, bool IsCloud); + /// -/// Wraps the OneNote COM API. Must be constructed and called on a background thread -/// (via Task.Run) because COM calls and Thread.Sleep block. -/// Dispose() must be called when the application closes. +/// Synchronous OneNote COM API implementation owned by . +/// Construction, all calls and disposal must stay on that worker's STA thread. /// -public class OneNoteService : IDisposable +internal sealed class OneNoteService : IDisposable { private Application? _oneNote; private bool _disposed = false; - private bool _oneNoteWasRunning = false; - private bool _oneNoteClosedAttempted = false; public OneNoteService() { - var oneNoteProcesses = Process.GetProcessesByName("ONENOTE"); - _oneNoteWasRunning = oneNoteProcesses.Length > 0; - try { _oneNote = new Application(); @@ -87,173 +82,65 @@ public List GetNotebooks() } /// - /// Exports a single notebook. Blocks the calling thread until finished. - /// Call via Task.Run from the UI layer. + /// Starts a notebook export and returns as soon as OneNote accepted the + /// publish request. File completion is monitored outside the STA thread. /// - public ExportResult ExportNotebook( + public PendingExport BeginNotebookExport( string notebookId, string destinationPath, string exportFormat = "onepkg", - IProgress? progress = null, - CancellationToken ct = default) + IProgress? progress = null) { if (_oneNote == null) throw new InvalidOperationException("OneNote is not initialized."); - TryCloseOneNoteGracefully(progress); - - var result = new ExportResult(); - - try - { - // Get notebook name and path from hierarchy - _oneNote.GetHierarchy(notebookId, HierarchyScope.hsSelf, out string xml); - var xdoc = XDocument.Parse(xml); - var notebookName = xdoc.Root?.Attribute("name")?.Value ?? "Notebook"; - var notebookPath = xdoc.Root?.Attribute("path")?.Value ?? ""; - - bool isCloud = notebookPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - notebookPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase); + // Get notebook name and path from hierarchy + _oneNote.GetHierarchy(notebookId, HierarchyScope.hsSelf, out string xml); + var xdoc = XDocument.Parse(xml); + var notebookName = xdoc.Root?.Attribute("name")?.Value ?? "Notebook"; + var notebookPath = xdoc.Root?.Attribute("path")?.Value ?? ""; - progress?.Report($"Exporting: {notebookName} (format: {exportFormat})"); + bool isCloud = notebookPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + notebookPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase); - // Determine file extension and PublishFormat - string fileExtension = exportFormat.ToLowerInvariant() switch - { - "xps" => ".xps", - "pdf" => ".pdf", - _ => ".onepkg" - }; - - PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch - { - "xps" => PublishFormat.pfXPS, - "pdf" => PublishFormat.pfPDF, - _ => PublishFormat.pfOneNotePackage - }; - - // Sanitize filename - var sanitizedName = string.Join("_", notebookName.Split(Path.GetInvalidFileNameChars())); - var fullPath = Path.Combine(destinationPath, sanitizedName + fileExtension); + progress?.Report($"Exporting: {notebookName} (format: {exportFormat})"); - Directory.CreateDirectory(destinationPath); - - // Open notebook (required for cloud notebooks) - _oneNote.OpenHierarchy(notebookPath, "", out string openedId, CreateFileType.cftNone); - - // Remove existing file so WaitForFile doesn't detect the old file as done - if (File.Exists(fullPath)) - { - progress?.Report("Removing previous export file..."); - File.Delete(fullPath); - } - - // Trigger the export (Publish returns immediately; OneNote writes async) - progress?.Report("OneNote is writing in the background..."); - _oneNote.Publish(openedId, fullPath, publishFormat, ""); - - // Wait for the file to appear and stabilise - result = WaitForFile(fullPath, isCloud, progress, ct); - - if (result.Success) - result.ExportedPath = fullPath; - } - catch (OperationCanceledException) + string fileExtension = exportFormat.ToLowerInvariant() switch { - result.Success = false; - result.Message = "Export cancelled."; - } - catch (UnauthorizedAccessException ex) - { - result.Success = false; - result.Message = $"Access denied: {ex.Message}"; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x8004201A)) - { - result.Success = false; - result.Message = "OneNote Error 0x8004201A: Cannot export. " + - "The notebook may have password-protected sections or be offline."; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x800706BA)) - { - result.Success = false; - result.Message = "OneNote RPC timeout (0x800706BA). " + - "Large notebooks may fail with PDF/XPS. Try .onepkg format instead."; - } - catch (COMException ex) - { - result.Success = false; - result.Message = $"OneNote COM error 0x{ex.HResult:X}: {ex.Message}"; - } - catch (Exception ex) - { - result.Success = false; - result.Message = $"Error during export: {ex.Message}"; - } - - return result; - } - - /// - /// Exports all notebooks sequentially. Calls ExportNotebook for each. - /// - public ExportResult ExportAllNotebooks( - string destinationPath, - string exportFormat = "onepkg", - IProgress? progress = null, - CancellationToken ct = default) - { - var result = new ExportResult { Success = true }; - int exported = 0, failed = 0; - var messages = new List(); + "xps" => ".xps", + "pdf" => ".pdf", + _ => ".onepkg" + }; - try + PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch { - var notebooks = GetNotebooks(); - progress?.Report($"Starting export of {notebooks.Count} notebook(s)..."); + "xps" => PublishFormat.pfXPS, + "pdf" => PublishFormat.pfPDF, + _ => PublishFormat.pfOneNotePackage + }; - foreach (var nb in notebooks) - { - ct.ThrowIfCancellationRequested(); + var sanitizedName = string.Join("_", notebookName.Split(Path.GetInvalidFileNameChars())); + var fullPath = Path.Combine(destinationPath, sanitizedName + fileExtension); - progress?.Report($"Exporting {exported + failed + 1}/{notebooks.Count}: {nb.Name}"); + Directory.CreateDirectory(destinationPath); - var r = ExportNotebook(nb.Id, destinationPath, exportFormat, progress, ct); + _oneNote.OpenHierarchy(notebookPath, "", out string openedId, CreateFileType.cftNone); - if (r.Success) - { - exported++; - messages.Add($"✓ {nb.Name}"); - } - else - { - failed++; - messages.Add($"✗ {nb.Name}: {r.Message}"); - } - } - - result.Success = failed == 0; - result.Message = $"Export completed: {exported} successful, {failed} failed\n\n" + - string.Join("\n", messages); - result.ExportedPath = destinationPath; - } - catch (OperationCanceledException) - { - result.Success = false; - result.Message = "Export cancelled."; - } - catch (Exception ex) + if (File.Exists(fullPath)) { - result.Success = false; - result.Message = $"Fatal export error: {ex.Message}"; + progress?.Report("Removing previous export file..."); + File.Delete(fullPath); } - return result; + progress?.Report("OneNote is writing in the background..."); + _oneNote.Publish(openedId, fullPath, publishFormat, ""); + + return new PendingExport(fullPath, isCloud); } /// /// Returns all exportable sections in a notebook (including those inside section groups). - /// Skips encrypted and recycle-bin entries. Call via Task.Run from the UI layer. + /// Skips encrypted and recycle-bin entries. Called by OneNoteComWorker on its STA thread. /// public List GetSections(string notebookId) { @@ -281,100 +168,55 @@ public List GetSections(string notebookId) } /// - /// Exports a single section. Blocks the calling thread until finished. - /// Sections are written into a notebook-named subfolder to avoid filename collisions. - /// Call via Task.Run from the UI layer. + /// Starts a section export and returns as soon as OneNote accepted the + /// publish request. File completion is monitored outside the STA thread. /// - public ExportResult ExportSection( + public PendingExport BeginSectionExport( SectionInfo section, string destinationPath, string exportFormat = "onepkg", - IProgress? progress = null, - CancellationToken ct = default) + IProgress? progress = null) { if (_oneNote == null) throw new InvalidOperationException("OneNote is not initialized."); - TryCloseOneNoteGracefully(progress); - - var result = new ExportResult(); + progress?.Report($"Exporting section: {section.Name} (format: {exportFormat})"); - try + string fileExtension = exportFormat.ToLowerInvariant() switch { - progress?.Report($"Exporting section: {section.Name} (format: {exportFormat})"); - - string fileExtension = exportFormat.ToLowerInvariant() switch - { - "xps" => ".xps", - "pdf" => ".pdf", - _ => ".onepkg" - }; + "xps" => ".xps", + "pdf" => ".pdf", + _ => ".onepkg" + }; - PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch - { - "xps" => PublishFormat.pfXPS, - "pdf" => PublishFormat.pfPDF, - _ => PublishFormat.pfOneNotePackage - }; + PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch + { + "xps" => PublishFormat.pfXPS, + "pdf" => PublishFormat.pfPDF, + _ => PublishFormat.pfOneNotePackage + }; - // Sections go into a notebook-named subfolder to prevent filename clashes - var sanitizedNb = string.Join("_", section.NotebookName.Split(Path.GetInvalidFileNameChars())); - var sanitizedSec = string.Join("_", section.Name.Split(Path.GetInvalidFileNameChars())); - var notebookDir = Path.Combine(destinationPath, sanitizedNb); - var fullPath = Path.Combine(notebookDir, sanitizedSec + fileExtension); + // Sections go into a notebook-named subfolder to prevent filename clashes + var sanitizedNb = string.Join("_", section.NotebookName.Split(Path.GetInvalidFileNameChars())); + var sanitizedSec = string.Join("_", section.Name.Split(Path.GetInvalidFileNameChars())); + var notebookDir = Path.Combine(destinationPath, sanitizedNb); + var fullPath = Path.Combine(notebookDir, sanitizedSec + fileExtension); - Directory.CreateDirectory(notebookDir); + Directory.CreateDirectory(notebookDir); - // Open the parent notebook (required before Publish on a section within it) - progress?.Report("Opening parent notebook..."); - _oneNote.OpenHierarchy(section.NotebookPath, "", out _, CreateFileType.cftNone); + progress?.Report("Opening parent notebook..."); + _oneNote.OpenHierarchy(section.NotebookPath, "", out _, CreateFileType.cftNone); - if (File.Exists(fullPath)) - { - progress?.Report("Removing previous export file..."); - File.Delete(fullPath); - } - - progress?.Report("OneNote is writing in the background..."); - _oneNote.Publish(section.Id, fullPath, publishFormat, ""); - - result = WaitForFile(fullPath, section.IsCloud, progress, ct); - - if (result.Success) - result.ExportedPath = fullPath; - } - catch (OperationCanceledException) + if (File.Exists(fullPath)) { - result.Success = false; - result.Message = "Export cancelled."; - } - catch (UnauthorizedAccessException ex) - { - result.Success = false; - result.Message = $"Access denied: {ex.Message}"; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x8004201A)) - { - result.Success = false; - result.Message = "Cannot export section (0x8004201A). It may be password-protected or offline."; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x800706BA)) - { - result.Success = false; - result.Message = "RPC timeout (0x800706BA). Try .onepkg format."; - } - catch (COMException ex) - { - result.Success = false; - result.Message = $"COM error 0x{ex.HResult:X}: {ex.Message}"; - } - catch (Exception ex) - { - result.Success = false; - result.Message = $"Error: {ex.Message}"; + progress?.Report("Removing previous export file..."); + File.Delete(fullPath); } - return result; + progress?.Report("OneNote is writing in the background..."); + _oneNote.Publish(section.Id, fullPath, publishFormat, ""); + + return new PendingExport(fullPath, section.IsCloud); } // ── Private helpers ────────────────────────────────────────────────────── @@ -383,7 +225,7 @@ public ExportResult ExportSection( /// Polls until the exported file exists and its size has been stable for 10 seconds. /// Cloud notebooks get a 30-minute timeout; local notebooks get 20 minutes. /// - private static ExportResult WaitForFile( + internal static async Task WaitForFileAsync( string fullPath, bool isCloud, IProgress? progress, @@ -397,7 +239,7 @@ private static ExportResult WaitForFile( long fileSize = 0; // Initial wait – give OneNote a moment to start writing - if (ct.WaitHandle.WaitOne(checkIntervalMs)) ct.ThrowIfCancellationRequested(); + await Task.Delay(checkIntervalMs, ct).ConfigureAwait(false); for (int i = 0; i < maxAttempts; i++) { @@ -447,7 +289,7 @@ private static ExportResult WaitForFile( progress?.Report($"Waiting for file creation... ({i * checkIntervalMs / 1000}s)"); } - if (ct.WaitHandle.WaitOne(checkIntervalMs)) ct.ThrowIfCancellationRequested(); + await Task.Delay(checkIntervalMs, ct).ConfigureAwait(false); } // Timed out @@ -463,6 +305,39 @@ private static ExportResult WaitForFile( }; } + internal static ExportResult CreateExportFailure(Exception exception) + { + return exception switch + { + UnauthorizedAccessException ex => new ExportResult + { + Success = false, + Message = $"Access denied: {ex.Message}" + }, + COMException ex when ex.HResult == unchecked((int)0x8004201A) => new ExportResult + { + Success = false, + Message = "OneNote error 0x8004201A: The export file already exists." + }, + COMException ex when ex.HResult == unchecked((int)0x800706BA) => new ExportResult + { + Success = false, + Message = "OneNote RPC timeout (0x800706BA). " + + "Large notebooks may fail with PDF/XPS. Try .onepkg format instead." + }, + COMException ex => new ExportResult + { + Success = false, + Message = $"OneNote COM error 0x{ex.HResult:X8}: {ex.Message}" + }, + _ => new ExportResult + { + Success = false, + Message = $"Error during export: {exception.Message}" + } + }; + } + /// /// Recursively walks a hierarchy XML element, collecting Section entries /// and descending into SectionGroups. Skips encrypted sections and the recycle bin. @@ -501,30 +376,6 @@ private static void CollectSections( } } - private void TryCloseOneNoteGracefully(IProgress? progress) - { - if (_oneNoteClosedAttempted) return; - _oneNoteClosedAttempted = true; - - var procs = Process.GetProcessesByName("ONENOTE"); - if (procs.Length == 0) return; - - progress?.Report($"Closing {procs.Length} OneNote process(es) before export..."); - - foreach (var p in procs) - { - try - { - if (p.CloseMainWindow()) - p.WaitForExit(5000); - } - catch { /* ignore */ } - finally { p.Dispose(); } - } - - Thread.Sleep(1000); - } - private static string FormatBytes(long bytes) { string[] units = { "Bytes", "KB", "MB", "GB" }; @@ -546,8 +397,6 @@ public void Dispose() { Marshal.ReleaseComObject(_oneNote); _oneNote = null; - GC.Collect(); - GC.WaitForPendingFinalizers(); } catch { /* ignore cleanup errors */ } } From d8b36bd8f5e55a760fc782443e0ba6d0a9c29514 Mon Sep 17 00:00:00 2001 From: "Jarvis (OpenClaw Agent)" Date: Tue, 1 Sep 2026 15:30:11 +0200 Subject: [PATCH 2/7] feat: add About dialog and prepare version 1.2.0 --- AboutWindow.xaml | 91 ++++++++++++++++++++++++++++++++++++++++++ AboutWindow.xaml.cs | 31 ++++++++++++++ App.xaml | 21 ++++++++++ CLAUDE.md | 5 ++- MainWindow.xaml | 9 +++++ MainWindow.xaml.cs | 6 +++ Models/Models.cs | 1 - OneNoteExporter.csproj | 2 +- README.md | 2 +- innosetup.iss | 88 ++++++++++++++++++++-------------------- 10 files changed, 207 insertions(+), 49 deletions(-) create mode 100644 AboutWindow.xaml create mode 100644 AboutWindow.xaml.cs diff --git a/AboutWindow.xaml b/AboutWindow.xaml new file mode 100644 index 0000000..04ae14a --- /dev/null +++ b/AboutWindow.xaml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +