diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 18c707a..4d02297 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -1,11 +1,8 @@
-# This workflow will build a .NET project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
-
-name: .NET
+name: CI
on:
- push:
- branches: [ "feature/*" ]
+ pull_request:
+ branches: [ "main" ]
env:
VERSION: 0.0.0-local
@@ -27,4 +24,4 @@ jobs:
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
- run: dotnet test -c Release --no-build --verbosity normal
\ No newline at end of file
+ run: dotnet test -c Release --no-build --verbosity normal
diff --git a/.github/workflows/gui-publish.yml b/.github/workflows/gui-publish.yml
new file mode 100644
index 0000000..66a0955
--- /dev/null
+++ b/.github/workflows/gui-publish.yml
@@ -0,0 +1,92 @@
+name: GUI Publish
+
+on:
+# push:
+# tags: [ "v*" ]
+ release:
+ types: [ published ]
+
+env:
+ VERSION: 0.0.0-local
+ GUI_PROJECT: src/Csv2Ofx.Gui/Csv2Ofx.Gui.csproj
+
+jobs:
+ test:
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Restore
+ run: dotnet restore
+ - name: Build
+ run: dotnet build --no-restore -c Release
+ - name: Test
+ run: dotnet test -c Release --no-build --verbosity normal
+
+ publish-gui:
+ needs: test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x64
+ artifact: csv2ofx-gui-linux-x64
+ - rid: osx-x64
+ artifact: csv2ofx-gui-osx-x64
+ - rid: osx-arm64
+ artifact: csv2ofx-gui-osx-arm64
+ - rid: win-x64
+ artifact: csv2ofx-gui-win-x64
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Set version
+ run: |
+ if [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" == v* ]]; then
+ VERSION="${GITHUB_REF_NAME#v}"
+ elif [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" == gui-v* ]]; then
+ VERSION="${GITHUB_REF_NAME#gui-v}"
+ else
+ VERSION="0.0.0-local"
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ - name: Publish self-contained GUI
+ run: |
+ dotnet publish "$GUI_PROJECT" \
+ -c Release \
+ -r "${{ matrix.rid }}" \
+ --self-contained true \
+ --framework net8.0 \
+ -p:PublishSingleFile=true \
+ -p:IncludeNativeLibrariesForSelfExtract=true \
+ -p:EnableCompressionInSingleFile=true \
+ -p:Version=${{ env.VERSION }} \
+ -o "artifacts/${{ matrix.artifact }}"
+ - name: Archive artifact
+ run: |
+ cd artifacts
+ zip -r "${{ matrix.artifact }}-${{ env.VERSION }}.zip" "${{ matrix.artifact }}"
+ - name: Upload folder artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: artifacts/${{ matrix.artifact }}/
+ - name: Upload zip artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}-zip
+ path: artifacts/${{ matrix.artifact }}-${{ env.VERSION }}.zip
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c7101c9..ae148fa 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,20 +1,17 @@
-# This workflow will build a .NET project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
-
-name: .NET
+name: CLI Publish
on:
- push:
- branches: [ "main" ]
- tags: [ "v*" ]
- pull_request:
- branches: [ "main" ]
+# push:
+# tags: [ "v*" ]
+ release:
+ types: [ published ]
env:
VERSION: 0.0.0-local
jobs:
test:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read # default is read; fine for checkout/build/test
diff --git a/.gitignore b/.gitignore
index 35063fc..cab3928 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ bld/
project.lock.json
project.fragment.lock.json
artifacts/
+.dotnet/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
@@ -51,4 +52,4 @@ CodeCoverage/
# NUnit
*.VisualState.xml
TestResult.xml
-nunit-*.xml
\ No newline at end of file
+nunit-*.xml
diff --git a/CsvToOfx.sln b/CsvToOfx.sln
index e1545e4..6735f84 100644
--- a/CsvToOfx.sln
+++ b/CsvToOfx.sln
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsvToOfx.Parsers.Tests", "t
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsvToOfx.Writers.Tests", "tests\CsvToOfx.Writers.Tests\CsvToOfx.Writers.Tests.csproj", "{0E04B7C2-2B3E-40A2-91FB-87361D9C1743}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csv2Ofx.Gui", "src\Csv2Ofx.Gui\Csv2Ofx.Gui.csproj", "{47CFDAC8-3576-4062-8C75-74E3C83A52B8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -64,6 +66,10 @@ Global
{0E04B7C2-2B3E-40A2-91FB-87361D9C1743}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E04B7C2-2B3E-40A2-91FB-87361D9C1743}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E04B7C2-2B3E-40A2-91FB-87361D9C1743}.Release|Any CPU.Build.0 = Release|Any CPU
+ {47CFDAC8-3576-4062-8C75-74E3C83A52B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {47CFDAC8-3576-4062-8C75-74E3C83A52B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {47CFDAC8-3576-4062-8C75-74E3C83A52B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {47CFDAC8-3576-4062-8C75-74E3C83A52B8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3904599D-3371-4E32-8A4D-199B4455BA3D} = {809E6210-9103-4155-8D19-4EFF1DB1B080}
@@ -74,5 +80,6 @@ Global
{B9F908B1-74B8-4420-8B06-3DEC5B38847D} = {6A01FB27-F518-4365-AB55-7D1898CEED3D}
{BEEE6E93-4F0E-4EB9-842E-839F9E225AE1} = {6A01FB27-F518-4365-AB55-7D1898CEED3D}
{0E04B7C2-2B3E-40A2-91FB-87361D9C1743} = {6A01FB27-F518-4365-AB55-7D1898CEED3D}
+ {47CFDAC8-3576-4062-8C75-74E3C83A52B8} = {809E6210-9103-4155-8D19-4EFF1DB1B080}
EndGlobalSection
EndGlobal
diff --git a/src/Csv2Ofx.Gui/App.axaml b/src/Csv2Ofx.Gui/App.axaml
new file mode 100644
index 0000000..d1e8e30
--- /dev/null
+++ b/src/Csv2Ofx.Gui/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/Csv2Ofx.Gui/App.axaml.cs b/src/Csv2Ofx.Gui/App.axaml.cs
new file mode 100644
index 0000000..41f42d2
--- /dev/null
+++ b/src/Csv2Ofx.Gui/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace Csv2Ofx.Gui;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/Csv2Ofx.Gui/Conversion/ConversionKind.cs b/src/Csv2Ofx.Gui/Conversion/ConversionKind.cs
new file mode 100644
index 0000000..6e49145
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/ConversionKind.cs
@@ -0,0 +1,6 @@
+namespace Csv2Ofx.Gui.Conversion;
+
+internal enum ConversionKind
+{
+ Investments
+}
diff --git a/src/Csv2Ofx.Gui/Conversion/ConversionProfile.cs b/src/Csv2Ofx.Gui/Conversion/ConversionProfile.cs
new file mode 100644
index 0000000..6559188
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/ConversionProfile.cs
@@ -0,0 +1,10 @@
+namespace Csv2Ofx.Gui.Conversion;
+
+internal sealed record ConversionProfile(
+ ConversionKind Kind,
+ string DisplayName,
+ string ProviderCode,
+ string OutputExtension)
+{
+ public override string ToString() => DisplayName;
+}
diff --git a/src/Csv2Ofx.Gui/Conversion/ConversionProfileCatalog.cs b/src/Csv2Ofx.Gui/Conversion/ConversionProfileCatalog.cs
new file mode 100644
index 0000000..cc240f2
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/ConversionProfileCatalog.cs
@@ -0,0 +1,13 @@
+namespace Csv2Ofx.Gui.Conversion;
+
+internal static class ConversionProfileCatalog
+{
+ public static IReadOnlyList All { get; } =
+ [
+ new ConversionProfile(
+ ConversionKind.Investments,
+ "Investments",
+ "fidelity",
+ ".ofx")
+ ];
+}
diff --git a/src/Csv2Ofx.Gui/Conversion/ConversionRequest.cs b/src/Csv2Ofx.Gui/Conversion/ConversionRequest.cs
new file mode 100644
index 0000000..2bdcd85
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/ConversionRequest.cs
@@ -0,0 +1,7 @@
+namespace Csv2Ofx.Gui.Conversion;
+
+internal sealed record ConversionRequest(
+ string CsvPath,
+ string OutputFolder,
+ string AccountName,
+ ConversionProfile Profile);
diff --git a/src/Csv2Ofx.Gui/Conversion/ConversionResult.cs b/src/Csv2Ofx.Gui/Conversion/ConversionResult.cs
new file mode 100644
index 0000000..1a3d7b5
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/ConversionResult.cs
@@ -0,0 +1,6 @@
+namespace Csv2Ofx.Gui.Conversion;
+
+internal sealed record ConversionResult(
+ string OutputPath,
+ int TransactionCount,
+ int SecurityCount);
diff --git a/src/Csv2Ofx.Gui/Conversion/InvestmentConversionService.cs b/src/Csv2Ofx.Gui/Conversion/InvestmentConversionService.cs
new file mode 100644
index 0000000..dbd2ee1
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Conversion/InvestmentConversionService.cs
@@ -0,0 +1,102 @@
+using CsvToOfx.Core.Models;
+using CsvToOfx.Core.Services;
+using CsvToOfx.Parsers.Abstractions;
+using CsvToOfx.Writers.Ofx;
+
+namespace Csv2Ofx.Gui.Conversion;
+
+internal sealed class InvestmentConversionService
+{
+ private readonly DateParser _dateParser = new();
+ private readonly AmountParser _amountParser = new();
+ private readonly FitIdGenerator _fitIdGenerator = new();
+ private readonly SubacctResolver _subacctResolver = new();
+ private readonly SecurityResolver _securityResolver = new(preferCusip: true);
+ private readonly IOfxWriter _ofxWriter = new OfxWriter();
+
+ public InvestmentConversionService()
+ {
+ ParserRegistry.Initialize(ParserFactory.DiscoverParsers());
+ }
+
+ public async Task ConvertAsync(ConversionRequest request, CancellationToken cancellationToken = default)
+ {
+ ValidateRequest(request);
+
+ var parser = ParserRegistry.Resolve(request.Profile.ProviderCode);
+ if (parser is null)
+ {
+ throw new InvalidOperationException($"No parser is registered for '{request.Profile.ProviderCode}'.");
+ }
+
+ Directory.CreateDirectory(request.OutputFolder);
+
+ await using var stream = File.OpenRead(request.CsvPath);
+ var raw = new RawStatement(request.Profile.ProviderCode, stream, ".csv");
+ var ctx = new ParserContext
+ {
+ AccountId = request.AccountName.Trim(),
+ Institution = request.Profile.ProviderCode,
+ CurrencyDefault = "USD",
+ DateParser = _dateParser,
+ AmountParser = _amountParser,
+ FitIdGenerator = _fitIdGenerator,
+ SubacctResolver = _subacctResolver,
+ SecurityResolver = _securityResolver
+ };
+
+ var result = parser.Parse(raw, ctx);
+ var outputPath = ResolveOutputPath(request);
+ var ofxText = _ofxWriter.WriteInvestmentStatement(result, includeSecurityList: true);
+
+ await File.WriteAllTextAsync(outputPath, ofxText, cancellationToken);
+ return new ConversionResult(outputPath, result.Transactions.Count, result.Securities?.Count ?? 0);
+ }
+
+ public string ResolveOutputPath(ConversionRequest request)
+ {
+ var csvName = Path.GetFileNameWithoutExtension(request.CsvPath);
+ var baseName = string.IsNullOrWhiteSpace(request.AccountName)
+ ? csvName
+ : $"{csvName}-{SanitizeFileName(request.AccountName)}";
+
+ var candidate = Path.Combine(request.OutputFolder, $"{baseName}{request.Profile.OutputExtension}");
+ var counter = 1;
+ while (File.Exists(candidate))
+ {
+ candidate = Path.Combine(request.OutputFolder, $"{baseName}_{counter++}{request.Profile.OutputExtension}");
+ }
+
+ return candidate;
+ }
+
+ private static void ValidateRequest(ConversionRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.CsvPath))
+ {
+ throw new InvalidOperationException("Choose a CSV file first.");
+ }
+
+ if (!File.Exists(request.CsvPath))
+ {
+ throw new FileNotFoundException("The selected CSV file was not found.", request.CsvPath);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.OutputFolder))
+ {
+ throw new InvalidOperationException("Choose an output folder.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.AccountName))
+ {
+ throw new InvalidOperationException("Enter an account name.");
+ }
+ }
+
+ private static string SanitizeFileName(string value)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var sanitized = new string(value.Trim().Select(c => invalid.Contains(c) ? '-' : c).ToArray());
+ return string.IsNullOrWhiteSpace(sanitized) ? "account" : sanitized;
+ }
+}
diff --git a/src/Csv2Ofx.Gui/Csv2Ofx.Gui.csproj b/src/Csv2Ofx.Gui/Csv2Ofx.Gui.csproj
new file mode 100644
index 0000000..ec40047
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Csv2Ofx.Gui.csproj
@@ -0,0 +1,23 @@
+
+
+
+ WinExe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Csv2Ofx.Gui/MainWindow.axaml b/src/Csv2Ofx.Gui/MainWindow.axaml
new file mode 100644
index 0000000..f343eed
--- /dev/null
+++ b/src/Csv2Ofx.Gui/MainWindow.axaml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Csv2Ofx.Gui/MainWindow.axaml.cs b/src/Csv2Ofx.Gui/MainWindow.axaml.cs
new file mode 100644
index 0000000..f8ecdfa
--- /dev/null
+++ b/src/Csv2Ofx.Gui/MainWindow.axaml.cs
@@ -0,0 +1,241 @@
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Csv2Ofx.Gui.Conversion;
+
+namespace Csv2Ofx.Gui;
+
+public partial class MainWindow : Window
+{
+ private readonly InvestmentConversionService _conversionService = new();
+ private bool _outputFolderWasAutoSelected = true;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ KindComboBox.ItemsSource = ConversionProfileCatalog.All;
+ KindComboBox.SelectedIndex = 0;
+ UpdateFormState();
+ }
+
+ private async void BrowseCsvButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ SetStatus("File picker is not available on this platform.", isError: true);
+ return;
+ }
+
+ var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Choose investment CSV",
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType("CSV files")
+ {
+ Patterns = ["*.csv"],
+ MimeTypes = ["text/csv", "application/csv", "application/vnd.ms-excel"]
+ },
+ FilePickerFileTypes.All
+ ]
+ });
+
+ var file = files.FirstOrDefault();
+ if (file is null)
+ {
+ return;
+ }
+
+ var path = file.Path.LocalPath;
+ CsvPathTextBox.Text = path;
+
+ var csvFolder = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(csvFolder) && ShouldApplyCsvFolderDefault())
+ {
+ OutputFolderTextBox.Text = csvFolder;
+ _outputFolderWasAutoSelected = true;
+ }
+
+ if (string.IsNullOrWhiteSpace(AccountNameTextBox.Text))
+ {
+ AccountNameTextBox.Text = Path.GetFileNameWithoutExtension(path);
+ }
+
+ SetStatus("CSV selected. Review the account name and output folder.");
+ UpdateFormState();
+ }
+
+ private async void BrowseOutputButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ SetStatus("Folder picker is not available on this platform.", isError: true);
+ return;
+ }
+
+ var options = new FolderPickerOpenOptions
+ {
+ Title = "Choose OFX output folder",
+ AllowMultiple = false
+ };
+
+ var folder = await TryGetFolderAsync(storageProvider, OutputFolderTextBox.Text);
+ if (folder is not null)
+ {
+ options.SuggestedStartLocation = folder;
+ }
+
+ var folders = await storageProvider.OpenFolderPickerAsync(options);
+ var selected = folders.FirstOrDefault();
+ if (selected is null)
+ {
+ return;
+ }
+
+ OutputFolderTextBox.Text = selected.Path.LocalPath;
+ _outputFolderWasAutoSelected = false;
+ SetStatus("Output folder selected.");
+ UpdateFormState();
+ }
+
+ private async void ConvertButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var request = TryBuildRequest();
+ if (request is null)
+ {
+ UpdateFormState();
+ return;
+ }
+
+ SetBusy(true);
+ SetStatus("Converting CSV to OFX...");
+
+ try
+ {
+ var result = await _conversionService.ConvertAsync(request);
+ SetStatus($"Wrote {result.TransactionCount} transactions and {result.SecurityCount} securities to {result.OutputPath}");
+ UpdateOutputPreview();
+ }
+ catch (Exception ex)
+ {
+ SetStatus(ex.Message, isError: true);
+ }
+ finally
+ {
+ SetBusy(false);
+ UpdateFormState();
+ }
+ }
+
+ private void Input_OnChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ UpdateFormState();
+ }
+
+ private void TextInput_OnChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (sender == OutputFolderTextBox)
+ {
+ _outputFolderWasAutoSelected = false;
+ }
+
+ UpdateFormState();
+ }
+
+ private ConversionRequest? TryBuildRequest()
+ {
+ if (KindComboBox.SelectedItem is not ConversionProfile profile)
+ {
+ SetStatus("Choose a statement kind.", isError: true);
+ return null;
+ }
+
+ var csvPath = CsvPathTextBox.Text?.Trim() ?? string.Empty;
+ var outputFolder = OutputFolderTextBox.Text?.Trim() ?? string.Empty;
+ var accountName = AccountNameTextBox.Text?.Trim() ?? string.Empty;
+
+ return new ConversionRequest(csvPath, outputFolder, accountName, profile);
+ }
+
+ private void UpdateFormState()
+ {
+ var request = TryBuildRequestWithoutStatus();
+ ConvertButton.IsEnabled =
+ request is not null
+ && File.Exists(request.CsvPath)
+ && Directory.Exists(request.OutputFolder)
+ && !string.IsNullOrWhiteSpace(request.AccountName)
+ && !ConversionProgressBar.IsVisible;
+
+ UpdateOutputPreview();
+ }
+
+ private ConversionRequest? TryBuildRequestWithoutStatus()
+ {
+ if (KindComboBox?.SelectedItem is not ConversionProfile profile)
+ {
+ return null;
+ }
+
+ var csvPath = CsvPathTextBox?.Text?.Trim() ?? string.Empty;
+ var outputFolder = OutputFolderTextBox?.Text?.Trim() ?? string.Empty;
+ var accountName = AccountNameTextBox?.Text?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(csvPath) || string.IsNullOrWhiteSpace(outputFolder))
+ {
+ return null;
+ }
+
+ return new ConversionRequest(csvPath, outputFolder, accountName, profile);
+ }
+
+ private void UpdateOutputPreview()
+ {
+ var request = TryBuildRequestWithoutStatus();
+ if (request is null || string.IsNullOrWhiteSpace(request.AccountName))
+ {
+ OutputPreviewTextBlock.Text = "The OFX path will appear here.";
+ return;
+ }
+
+ OutputPreviewTextBlock.Text = _conversionService.ResolveOutputPath(request);
+ }
+
+ private bool ShouldApplyCsvFolderDefault()
+ {
+ return _outputFolderWasAutoSelected || string.IsNullOrWhiteSpace(OutputFolderTextBox.Text);
+ }
+
+ private void SetBusy(bool isBusy)
+ {
+ ConversionProgressBar.IsVisible = isBusy;
+ BrowseCsvButton.IsEnabled = !isBusy;
+ BrowseOutputButton.IsEnabled = !isBusy;
+ KindComboBox.IsEnabled = !isBusy;
+ AccountNameTextBox.IsEnabled = !isBusy;
+ OutputFolderTextBox.IsEnabled = !isBusy;
+ ConvertButton.IsEnabled = !isBusy;
+ }
+
+ private void SetStatus(string message, bool isError = false)
+ {
+ StatusTextBlock.Text = message;
+ StatusTextBlock.Classes.Set("error", isError);
+ }
+
+ private IStorageProvider? GetStorageProvider()
+ {
+ return TopLevel.GetTopLevel(this)?.StorageProvider;
+ }
+
+ private static async Task TryGetFolderAsync(IStorageProvider storageProvider, string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
+ {
+ return null;
+ }
+
+ return await storageProvider.TryGetFolderFromPathAsync(new Uri(path));
+ }
+}
diff --git a/src/Csv2Ofx.Gui/Program.cs b/src/Csv2Ofx.Gui/Program.cs
new file mode 100644
index 0000000..ce28163
--- /dev/null
+++ b/src/Csv2Ofx.Gui/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+
+namespace Csv2Ofx.Gui;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ {
+ return AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+ }
+}