Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,18 @@ jobs:

- name: Create SHA-256 checksum
shell: pwsh
env:
APP_VERSION: ${{ steps.version.outputs.number }}
run: |
$installer = Get-Item "artifacts\OneNoteBackupExporter_Setup_$env:APP_VERSION.exe"
$installer = Get-Item "artifacts\OneNoteBackupExporter_Setup.exe"
$hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $($installer.Name)" | Set-Content "$($installer.FullName).sha256" -Encoding ascii

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: OneNoteBackupExporter-${{ steps.version.outputs.number }}
name: OneNoteBackupExporter-Windows-x64
path: |
artifacts/OneNoteBackupExporter_Setup_${{ steps.version.outputs.number }}.exe
artifacts/OneNoteBackupExporter_Setup_${{ steps.version.outputs.number }}.exe.sha256
artifacts/OneNoteBackupExporter_Setup.exe
artifacts/OneNoteBackupExporter_Setup.exe.sha256
if-no-files-found: error
retention-days: 14

Expand All @@ -131,7 +129,7 @@ jobs:
- name: Download installer artifact
uses: actions/download-artifact@v4
with:
name: OneNoteBackupExporter-${{ needs.build.outputs.version }}
name: OneNoteBackupExporter-Windows-x64
path: artifacts

- name: Create release and upload installer
Expand Down
91 changes: 91 additions & 0 deletions AboutWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<Window x:Class="OneNoteExporter.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Icon="icon.ico"
Title="About OneNote Backup Exporter"
Width="430" Height="305"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource BgPage}">

<Border Margin="18"
Padding="22"
Background="{StaticResource BgCard}"
BorderBrush="{StaticResource BorderColor}"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>

<Image Grid.Column="0"
Source="icon.ico"
Width="52" Height="52"
Margin="0,0,15,0"/>

<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="OneNote Backup Exporter"
FontSize="19"
FontWeight="Bold"
Foreground="{StaticResource PrimaryBlue}"/>
<TextBlock x:Name="VersionText"
Margin="0,4,0,0"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"/>
<TextBlock x:Name="BuildText"
Margin="0,2,0,0"
FontSize="11"
Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
</Grid>

<Border Grid.Row="1"
Margin="0,18,0,14"
Padding="12,9"
Background="{StaticResource InfoBg}"
BorderBrush="{StaticResource InfoBorder}"
BorderThickness="1"
CornerRadius="5">
<TextBlock Text="Reliable backups and exports for OneNote Desktop."
TextAlignment="Center"
Foreground="{StaticResource InfoFg}"
FontSize="12"/>
</Border>

<StackPanel Grid.Row="2">
<TextBlock Text="Supports OneNote packages, PDF, XPS and local backup copies."
TextWrapping="Wrap"
TextAlignment="Center"
Foreground="{StaticResource TextSecondary}"
FontSize="12"/>
<TextBlock Text="© 2026 JLI Software"
Margin="0,8,0,0"
TextAlignment="Center"
Foreground="{StaticResource TextSecondary}"
FontSize="11"/>
</StackPanel>

<Button Grid.Row="3"
Content="Close"
Style="{StaticResource SmallButtonStyle}"
Width="90"
Margin="0,16,0,0"
HorizontalAlignment="Right"
IsDefault="True"
IsCancel="True"
Click="CloseButton_Click"/>
</Grid>
</Border>
</Window>
31 changes: 31 additions & 0 deletions AboutWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Reflection;
using System.Windows;

namespace OneNoteExporter;

public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();

var assembly = Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var versionParts = informationalVersion?.Split('+', 2);
var version = versionParts is { Length: > 0 } && !string.IsNullOrWhiteSpace(versionParts[0])
? versionParts[0]
: assembly.GetName().Version?.ToString(3) ?? "Unknown";
var configuration = assembly
.GetCustomAttribute<AssemblyConfigurationAttribute>()?
.Configuration ?? "Release";

VersionText.Text = $"Version {version}";
BuildText.Text = versionParts is { Length: 2 }
? $"{configuration} build · {versionParts[1][..Math.Min(7, versionParts[1].Length)]}"
: $"{configuration} build";
}

private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
}
21 changes: 21 additions & 0 deletions App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@
</Style.Triggers>
</Style>

<!-- Compact header button for About/version information -->
<Style x:Key="AboutButtonStyle" TargetType="Button">
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryBlue}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template" Value="{StaticResource BorderedButtonTemplate}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#EEF4FF"/>
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBlue}"/>
</Trigger>
</Style.Triggers>
</Style>

<!-- ===== TextBox Style ===== -->
<Style TargetType="TextBox">
<Setter Property="Padding" Value="8,6"/>
Expand Down
5 changes: 4 additions & 1 deletion App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Windows;
using System.Windows.Threading;
using OneNoteExporter.Helpers;

namespace OneNoteExporter;

Expand All @@ -16,7 +17,9 @@ protected override void OnStartup(StartupEventArgs e)
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
MessageBox.Show(
$"An unexpected error occurred:\n\n{e.Exception.Message}",
UserFacingError.Describe(
e.Exception,
"An unexpected error occurred. Please restart the app and try again."),
"OneNote Exporter – Error",
MessageBoxButton.OK,
MessageBoxImage.Error);
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## 1.2.0

- Improved reliability with a serialized OneNote COM worker, safe cancellation, and bounded connection recovery.
- Protected existing backups with staging files and atomic replacement.
- Added safer PDF/XPS handling for large notebooks, including automatic per-section export.
- Prevented filename collisions while preserving OneNote section-group folders.
- Added an About dialog with version and build information.
- Standardized all user-facing errors, progress messages, and release tooling in English.
69 changes: 49 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -26,17 +28,19 @@ OneNoteExporterC#/
├── OneNoteExporter.sln
├── App.xaml Global styles (colors, buttons, inputs)
├── App.xaml.cs Global exception handler
├── AboutWindow.xaml/.cs About dialog with runtime version/build metadata
├── MainWindow.xaml Full UI layout
├── MainWindow.xaml.cs All UI logic, async export flow
├── Models/
│ └── 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
├── FileHelper.cs Local backup path, CopyLocalBackup, OpenFolder,
│ GetDefaultDownloadsPath
└── UserFacingError.cs Stable English error text and language-neutral codes
```

## Development Commands
Expand All @@ -52,6 +56,17 @@ dotnet run
dotnet publish -c Release -r win-x64 --self-contained true
```

## Release Asset Naming

Public release assets must use stable, version-independent names so `releases/latest`
download URLs remain permanent:

- `OneNoteBackupExporter_Setup.exe`
- `OneNoteBackupExporter_Setup.exe.sha256`

Do not add the version number to public release asset filenames. Keep the semantic version
in the project, installer metadata, Git tag, GitHub release title, and About dialog instead.

## Critical Technical Details

### COM Compatibility
Expand All @@ -60,17 +75,25 @@ dotnet publish -c Release -r win-x64 --self-contained true
- **Do NOT add `<UseWindowsForms>true</UseWindowsForms>`** – 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<string>` for progress reporting (marshals back to UI thread automatically via `Progress<T>`)
- 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)
5. Timeout: 20 min for local notebooks, 30 min for cloud (SharePoint/OneDrive)
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()` publishes to a unique same-directory staging file on the STA worker
4. Recoverable COM/RPC failures get one bounded retry; disconnected COM objects are recreated and validated on the STA thread
5. Async file polling waits until size is stable for 10 seconds while the STA message pump stays responsive
6. A successful staging file atomically replaces the final export; previous good backups remain untouched on failure
7. Timeout: 20 min for local notebooks, 30 min for cloud (SharePoint/OneDrive); polling timeouts are not retried
8. OneNote does not expose notebook byte size through COM. Whole-notebook PDF/XPS
jobs therefore show a preflight choice: automatically split into individual
section exports (recommended), switch to `.onepkg`, or explicitly try the whole
rendered export anyway

### Export Formats
| UI value | PublishFormat | Extension |
Expand All @@ -86,23 +109,26 @@ dotnet publish -c Release -r win-x64 --self-contained true
- When selected in dropdown: all notebooks auto-checked, checkboxes disabled, warning shown

### OneNote COM Cleanup
- `OneNoteService.Dispose()` is called in `MainWindow.Window_Closing`
- `Dispose()` calls `Marshal.ReleaseComObject()` – does NOT kill the OneNote process
- `OneNoteComWorker.Dispose()` is called in `MainWindow.Window_Closing`
- The worker releases the COM object on its owning STA thread and does NOT kill OneNote
- OneNote continues running in background after app closes (by design, avoids "could not be started on last attempt" error)

### Known COM Errors
| HResult | Cause | Action |
|---|---|---|
| `0x8004201A` | Password-protected sections or offline | Show message, suggest unlocking |
| `0x800706BA` | RPC timeout (large notebook, PDF/XPS) | Suggest .onepkg format |
| `0x8004201A` | Export file already exists | Report the target-file collision |
| `0x8004201D` | Notebook is not fully synchronized | Ask the user to finish sync |
| `0x80042023` | OneNote internal timeout | Report timeout; suggest .onepkg for large PDF/XPS jobs |
| `0x80042030` | A modal OneNote dialog blocks COM | Ask the user to close the dialog |
| `0x800706BA` | OneNote RPC server unavailable/disconnected | Reconnect once, then report actionable COM guidance |
| `0x80070005` | Access denied | Check permissions |
| `0x80042010` | Notebook not accessible | Open in OneNote first |

## UI Design

### Layout (3-row Grid)
```
Row 0 (Auto): Header – title (#0078d7) + version badge (dynamic color)
Row 0 (Auto): Header – title, OneNote status badge and About/version button
Row 1 (*): Notebook list (ScrollViewer + ItemsControl with CheckBoxes)
Row 2 (Auto): Export section (path, format dropdown, warning, buttons, status)
```
Expand All @@ -121,7 +147,10 @@ Row 2 (Auto): Export section (path, format dropdown, warning, buttons, status)
- List background: `#F8F9FA`

### UI Language
**English** – all UI strings are in English (matching the original Go/Wails frontend).
**English** – all user-facing UI, error, progress, dialog, status, and completion strings
must remain in English. Do not display raw `Exception.Message` values because Windows and
OneNote COM messages follow the operating-system language. Use `UserFacingError` and a
language-neutral HRESULT/error code while keeping internal diagnostic output non-sensitive.

### Key Behaviors
- Export button disabled until ≥1 notebook selected; text = `"Export N notebook(s)"`
Expand Down
15 changes: 12 additions & 3 deletions Helpers/FileHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ public static BackupAvailability CheckLocalBackupAvailable()
{
Available = false,
Reason = "read_error",
Message = $"Error reading backup folder: {ex.Message}",
Message = UserFacingError.Describe(
ex,
"The OneNote backup folder could not be read."),
Path = backupPath
};
}
Expand Down Expand Up @@ -104,7 +106,13 @@ public static ExportResult CopyLocalBackup(string destPath)
}
catch (Exception ex)
{
return new ExportResult { Success = false, Message = $"Error copying backup: {ex.Message}" };
return new ExportResult
{
Success = false,
Message = UserFacingError.Describe(
ex,
"The local OneNote backup could not be copied.")
};
}
}

Expand All @@ -122,7 +130,8 @@ public static void OpenFolder(string path)
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open folder: {ex.Message}");
Debug.WriteLine(
UserFacingError.Describe(ex, "The destination folder could not be opened."));
}
}

Expand Down
Loading
Loading