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
11 changes: 4 additions & 7 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
run: dotnet test -c Release --no-build --verbosity normal
92 changes: 92 additions & 0 deletions .github/workflows/gui-publish.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 6 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ bld/
project.lock.json
project.fragment.lock.json
artifacts/
.dotnet/

# ASP.NET Scaffolding
ScaffoldingReadMe.txt
Expand All @@ -51,4 +52,4 @@ CodeCoverage/
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
nunit-*.xml
7 changes: 7 additions & 0 deletions CsvToOfx.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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
8 changes: 8 additions & 0 deletions src/Csv2Ofx.Gui/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Application
x:Class="Csv2Ofx.Gui.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
23 changes: 23 additions & 0 deletions src/Csv2Ofx.Gui/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
6 changes: 6 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/ConversionKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Csv2Ofx.Gui.Conversion;

internal enum ConversionKind
{
Investments
}
10 changes: 10 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/ConversionProfile.cs
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/ConversionProfileCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Csv2Ofx.Gui.Conversion;

internal static class ConversionProfileCatalog
{
public static IReadOnlyList<ConversionProfile> All { get; } =
[
new ConversionProfile(
ConversionKind.Investments,
"Investments",
"fidelity",
".ofx")
];
}
7 changes: 7 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/ConversionRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Csv2Ofx.Gui.Conversion;

internal sealed record ConversionRequest(
string CsvPath,
string OutputFolder,
string AccountName,
ConversionProfile Profile);
6 changes: 6 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/ConversionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Csv2Ofx.Gui.Conversion;

internal sealed record ConversionResult(
string OutputPath,
int TransactionCount,
int SecurityCount);
102 changes: 102 additions & 0 deletions src/Csv2Ofx.Gui/Conversion/InvestmentConversionService.cs
Original file line number Diff line number Diff line change
@@ -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<ConversionResult> 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;
}
}
23 changes: 23 additions & 0 deletions src/Csv2Ofx.Gui/Csv2Ofx.Gui.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\CsvToOfx.Core\CsvToOfx.Core.csproj" />
<ProjectReference Include="..\CsvToOfx.Parsers\CsvToOfx.Parsers.csproj" />
<ProjectReference Include="..\CsvToOfx.Writers.Ofx\CsvToOfx.Writers.Ofx.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
</ItemGroup>

</Project>
Loading
Loading