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 @@ + + + + + + + + + + + +