Browse Source

Extend import of activities with Ghostfolio AI

pull/8128/head
Thomas Kaul 5 days ago
parent
commit
6c73504fb6
  1. 186
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  2. 64
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  3. 10
      apps/client/src/app/services/import-activities.service.ts
  4. 12
      libs/ui/src/lib/services/data.service.ts

186
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -1,13 +1,22 @@
import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { DEFAULT_DATE_RANGE } from '@ghostfolio/common/config'; import {
DEFAULT_DATE_RANGE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { import {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateOrderDto,
CreatePlatformDto, CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces'; import {
Activity,
AiActivityRow,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AssetClass } from '@ghostfolio/prisma/enums'; import { AssetClass } from '@ghostfolio/prisma/enums';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -19,6 +28,7 @@ import {
StepperSelectionEvent StepperSelectionEvent
} from '@angular/cdk/stepper'; } from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -34,6 +44,10 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import {
MatCheckboxChange,
MatCheckboxModule
} from '@angular/material/checkbox';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -42,17 +56,24 @@ import {
import { MatExpansionModule } from '@angular/material/expansion'; import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatStepper, MatStepperModule } from '@angular/material/stepper'; import { MatStepper, MatStepperModule } from '@angular/material/stepper';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { cloudUploadOutline, warningOutline } from 'ionicons/icons'; import {
cloudUploadOutline,
informationCircleOutline,
warningOutline
} from 'ionicons/icons';
import { isArray, sortBy } from 'lodash-es'; import { isArray, sortBy } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { from, switchMap } from 'rxjs';
import { ImportStep } from './enums/import-step'; import { ImportStep } from './enums/import-step';
import { ImportActivitiesDialogParams } from './interfaces/interfaces'; import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@ -68,12 +89,15 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
GfFileDropDirective, GfFileDropDirective,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatDialogModule, MatDialogModule,
MatExpansionModule, MatExpansionModule,
MatFormFieldModule, MatFormFieldModule,
MatProgressBarModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatSelectModule, MatSelectModule,
MatStepperModule, MatStepperModule,
MatTooltipModule,
ReactiveFormsModule ReactiveFormsModule
], ],
selector: 'gf-import-activities-dialog', selector: 'gf-import-activities-dialog',
@ -81,6 +105,14 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
templateUrl: 'import-activities-dialog.html' templateUrl: 'import-activities-dialog.html'
}) })
export class GfImportActivitiesDialogComponent { export class GfImportActivitiesDialogComponent {
private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI = [
'csv',
'jpeg',
'jpg',
'pdf',
'png'
];
protected readonly assetProfileForm = new FormGroup({ protected readonly assetProfileForm = new FormGroup({
assetProfileIdentifier: new FormControl<PortfolioPosition | null>(null, { assetProfileIdentifier: new FormControl<PortfolioPosition | null>(null, {
validators: [Validators.required] validators: [Validators.required]
@ -92,12 +124,20 @@ export class GfImportActivitiesDialogComponent {
protected details: any[] = []; protected details: any[] = [];
protected dialogTitle = $localize`Import Activities`; protected dialogTitle = $localize`Import Activities`;
protected errorMessages: string[] = []; protected errorMessages: string[] = [];
protected readonly ghostfolioAiTooltip = $localize`Only upload anonymized or redacted data, as it will be sent to third parties.`;
protected readonly hasPermissionToImportActivitiesWithAi = hasPermission(
this.data.user?.permissions,
permissions.importActivitiesWithAi
);
protected holdings: PortfolioPosition[] = []; protected holdings: PortfolioPosition[] = [];
protected importStep: ImportStep = ImportStep.UPLOAD_FILE; protected importStep: ImportStep = ImportStep.UPLOAD_FILE;
protected isGhostfolioAiEnabled = false;
protected isLoading = false; protected isLoading = false;
protected readonly maxActivitiesOfGhostfolioAi = MCP_MAX_ACTIVITIES;
protected mode: 'DIVIDEND'; protected mode: 'DIVIDEND';
protected pageIndex = 0; protected pageIndex = 0;
protected readonly pageSize = 8; protected readonly pageSize = 8;
protected rowsWithoutMatch: AiActivityRow[] = [];
protected selectedActivities: Activity[] = []; protected selectedActivities: Activity[] = [];
protected readonly sortColumn = 'date'; protected readonly sortColumn = 'date';
protected readonly sortDirection: SortDirection = 'desc'; protected readonly sortDirection: SortDirection = 'desc';
@ -120,7 +160,7 @@ export class GfImportActivitiesDialogComponent {
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
public constructor() { public constructor() {
addIcons({ cloudUploadOutline, warningOutline }); addIcons({ cloudUploadOutline, informationCircleOutline, warningOutline });
} }
public ngOnInit() { public ngOnInit() {
@ -219,6 +259,10 @@ export class GfImportActivitiesDialogComponent {
this.handleFile({ stepper, file: files[0] }); this.handleFile({ stepper, file: files[0] });
} }
protected onGhostfolioAiChange({ checked }: MatCheckboxChange) {
this.isGhostfolioAiEnabled = checked;
}
protected onImportStepChange(event: StepperSelectionEvent) { protected onImportStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === ImportStep.UPLOAD_FILE) { if (event.selectedIndex === ImportStep.UPLOAD_FILE) {
this.importStep = ImportStep.UPLOAD_FILE; this.importStep = ImportStep.UPLOAD_FILE;
@ -265,6 +309,7 @@ export class GfImportActivitiesDialogComponent {
this.errorMessages = []; this.errorMessages = [];
this.importStep = ImportStep.SELECT_ACTIVITIES; this.importStep = ImportStep.SELECT_ACTIVITIES;
this.pageIndex = 0; this.pageIndex = 0;
this.rowsWithoutMatch = [];
this.assetProfileForm.controls.assetProfileIdentifier.enable(); this.assetProfileForm.controls.assetProfileIdentifier.enable();
aStepper.reset(); aStepper.reset();
@ -273,7 +318,16 @@ export class GfImportActivitiesDialogComponent {
protected onSelectFile(stepper: MatStepper) { protected onSelectFile(stepper: MatStepper) {
const input = document.createElement('input'); const input = document.createElement('input');
input.accept = 'application/JSON, .csv'; input.accept = this.isGhostfolioAiEnabled
? [
'application/JSON',
...GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.map(
(fileExtension) => {
return `.${fileExtension}`;
}
)
].join(', ')
: 'application/JSON, .csv';
input.type = 'file'; input.type = 'file';
input.onchange = (event) => { input.onchange = (event) => {
@ -295,6 +349,19 @@ export class GfImportActivitiesDialogComponent {
} }
private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) { private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) {
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (
this.isGhostfolioAiEnabled &&
GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.includes(
fileExtension ?? ''
)
) {
void this.handleFileWithGhostfolioAi({ file, fileExtension, stepper });
return;
}
this.snackBar.open('⏳ ' + $localize`Validating data...`); this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Setting up the reader // Setting up the reader
@ -303,7 +370,6 @@ export class GfImportActivitiesDialogComponent {
reader.onload = async (readerEvent) => { reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target?.result as string; const fileContent = readerEvent.target?.result as string;
const fileExtension = file.name.split('.').pop()?.toLowerCase();
try { try {
if (fileExtension === 'json') { if (fileExtension === 'json') {
@ -396,15 +462,106 @@ export class GfImportActivitiesDialogComponent {
error: { error: { message: ['Unexpected format'] } } error: { error: { message: ['Unexpected format'] } }
}); });
} finally { } finally {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss(); this.snackBar.dismiss();
this.updateSelection(this.activities); this.showSelectActivitiesStep(stepper);
}
};
}
stepper.next(); private async handleFileWithGhostfolioAi({
file,
fileExtension,
stepper
}: {
file: File;
fileExtension?: string;
stepper: MatStepper;
}) {
this.accounts = [];
this.activities = [];
this.assetProfiles = [];
this.platforms = [];
this.tags = [];
// A CSV file is checked before the upload, because a file which the
// server rejects counts towards the daily limit of Ghostfolio AI
if (
fileExtension === 'csv' &&
this.importActivitiesService.getDataRowCountOfCsv(await file.text()) >
MCP_MAX_ACTIVITIES
) {
this.handleImportError({
activities: [],
error: {
error: {
message: [
$localize`The CSV file has more than ${MCP_MAX_ACTIVITIES}:maxActivities: rows of activities`
]
}
}
});
this.showSelectActivitiesStep(stepper);
return;
}
this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
let activitiesOfFile: CreateOrderDto[] = [];
// The request is canceled if the dialog is closed
this.dataService
.postAiActivities(file)
.pipe(
switchMap(({ activities, rowsWithoutMatch }) => {
activitiesOfFile = activities;
this.rowsWithoutMatch = rowsWithoutMatch;
return from(
this.importActivitiesService.importJson({
activities,
isDryRun: true
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: (error: HttpErrorResponse) => {
console.error(error);
const { message } = (error.error ?? {}) as { message?: unknown };
this.handleImportError({
activities: activitiesOfFile,
error: {
error: {
message: isArray(message)
? message
: [
$localize`Oops! Something went wrong.` +
' ' +
$localize`Please try again later.`
]
} }
}; }
});
this.isLoading = false;
this.showSelectActivitiesStep(stepper);
},
next: ({ activities }) => {
this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0;
this.totalItems = activities.length;
this.isLoading = false;
this.showSelectActivitiesStep(stepper);
}
});
} }
private handleImportError({ private handleImportError({
@ -430,4 +587,13 @@ export class GfImportActivitiesDialogComponent {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
private showSelectActivitiesStep(stepper: MatStepper) {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.updateSelection(this.activities);
stepper.next();
this.changeDetectorRef.markForCheck();
}
} }

64
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -74,6 +74,12 @@
</form> </form>
} @else { } @else {
<div class="d-flex flex-column justify-content-center"> <div class="d-flex flex-column justify-content-center">
@if (isLoading) {
<div class="drop-area p-4 text-center text-muted">
<span i18n>Ghostfolio AI is reading the file...</span>
<mat-progress-bar class="mt-3" mode="indeterminate" />
</div>
} @else {
<button <button
class="drop-area p-4 text-center text-muted" class="drop-area p-4 text-center text-muted"
gfFileDrop gfFileDrop
@ -87,6 +93,39 @@
<span i18n>Choose or drop a file here</span> <span i18n>Choose or drop a file here</span>
</div> </div>
</button> </button>
}
@if (hasPermissionToImportActivitiesWithAi) {
<div class="mt-3 text-center">
<div class="align-items-center d-flex justify-content-center">
<mat-checkbox
color="primary"
[checked]="isGhostfolioAiEnabled"
[disabled]="isLoading"
(change)="onGhostfolioAiChange($event)"
>
<ng-container i18n
>Smart import with Ghostfolio AI</ng-container
>
</mat-checkbox>
<span
class="align-items-center d-flex"
matTooltipPosition="above"
[matTooltip]="ghostfolioAiTooltip"
>
<ion-icon name="information-circle-outline" />
</span>
</div>
@if (isGhostfolioAiEnabled) {
<p class="mb-0 mt-1">
<small i18n
>Ghostfolio AI also reads CSV files of other brokers and
JPG, PDF and PNG files with up to
{{ maxActivitiesOfGhostfolioAi }} activities.</small
>
</p>
}
</div>
}
<p class="mb-0 mt-3 text-center"> <p class="mb-0 mt-3 text-center">
<small> <small>
<span class="mr-1" i18n <span class="mr-1" i18n
@ -121,6 +160,31 @@
<div> <div>
@if (errorMessages?.length === 0) { @if (errorMessages?.length === 0) {
@if (importStep === 1) { @if (importStep === 1) {
@if (rowsWithoutMatch.length > 0) {
<mat-accordion class="d-block mb-3" displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline" />
</div>
<ng-container i18n>{rowsWithoutMatch.length, plural,
=1 {
Ghostfolio AI could not match 1 activity of the file
}
other {
Ghostfolio AI could not match {{
rowsWithoutMatch.length }} activities of the file
}
}</ng-container>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
class="m-0"
><code>{{ rowsWithoutMatch | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
}
<gf-activities-table <gf-activities-table
[baseCurrency]="data?.user?.settings?.baseCurrency" [baseCurrency]="data?.user?.settings?.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"

10
apps/client/src/app/services/import-activities.service.ts

@ -41,6 +41,16 @@ export class ImportActivitiesService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
/**
* Gives the number of the lines of a CSV file which are not empty, without
* the first line
*/
public getDataRowCountOfCsv(fileContent: string) {
const { data } = csvToJson(fileContent, { skipEmptyLines: true });
return Math.max(data.length - 1, 0);
}
public async importCsv({ public async importCsv({
fileContent, fileContent,
isDryRun = false, isDryRun = false,

12
libs/ui/src/lib/services/data.service.ts

@ -26,6 +26,7 @@ import {
AccountsResponse, AccountsResponse,
ActivitiesResponse, ActivitiesResponse,
ActivityResponse, ActivityResponse,
AiActivitiesResponse,
AiPromptResponse, AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
@ -884,6 +885,17 @@ export class DataService {
return this.http.post<CreateActivityResponse>('/api/v1/activities', aOrder); return this.http.post<CreateActivityResponse>('/api/v1/activities', aOrder);
} }
public postAiActivities(file: File) {
const formData = new FormData();
formData.append('file', file);
return this.http.post<AiActivitiesResponse>(
'/api/v1/ai/activities',
formData
);
}
public postApiKey() { public postApiKey() {
return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {}); return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {});
} }

Loading…
Cancel
Save