Feature/export draft activities as ics (#830)
* Export draft activities as ICS * Update changelog
This commit is contained in:
parent
f1feb04f29
commit
8526b5a027
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to export future activities (drafts) as `.ics` files
|
||||||
|
|
||||||
## 1.136.0 - 13.04.2022
|
## 1.136.0 - 13.04.2022
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -42,6 +42,7 @@ export class ExportService {
|
|||||||
accountId,
|
accountId,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
type,
|
type,
|
||||||
@ -49,13 +50,14 @@ export class ExportService {
|
|||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
date,
|
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -211,14 +211,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
downloadAsFile(
|
downloadAsFile({
|
||||||
data,
|
content: data,
|
||||||
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
|
fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
|
||||||
parseISO(data.meta.date),
|
parseISO(data.meta.date),
|
||||||
'yyyyMMddHHmm'
|
'yyyyMMddHHmm'
|
||||||
)}.json`,
|
)}.json`,
|
||||||
'text/plain'
|
format: 'json'
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
|
|||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
|
private icsService: IcsService,
|
||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private importTransactionsService: ImportTransactionsService,
|
private importTransactionsService: ImportTransactionsService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
.fetchExport(activityIds)
|
.fetchExport(activityIds)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
downloadAsFile(
|
for (const activity of data.activities) {
|
||||||
data,
|
delete activity.id;
|
||||||
`ghostfolio-export-${format(
|
}
|
||||||
|
|
||||||
|
downloadAsFile({
|
||||||
|
content: data,
|
||||||
|
fileName: `ghostfolio-export-${format(
|
||||||
parseISO(data.meta.date),
|
parseISO(data.meta.date),
|
||||||
'yyyyMMddHHmm'
|
'yyyyMMddHHmm'
|
||||||
)}.json`,
|
)}.json`,
|
||||||
'text/plain'
|
format: 'json'
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExportDrafts(activityIds?: string[]) {
|
||||||
|
this.dataService
|
||||||
|
.fetchExport(activityIds)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
downloadAsFile({
|
||||||
|
content: this.icsService.transformActivitiesToIcsContent(
|
||||||
|
data.activities
|
||||||
|
),
|
||||||
|
fileName: `ghostfolio-drafts-${format(
|
||||||
|
parseISO(data.meta.date),
|
||||||
|
'yyyyMMddHHmm'
|
||||||
|
)}.ics`,
|
||||||
|
format: 'string'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
(activityToClone)="onCloneTransaction($event)"
|
(activityToClone)="onCloneTransaction($event)"
|
||||||
(activityToUpdate)="onUpdateTransaction($event)"
|
(activityToUpdate)="onUpdateTransaction($event)"
|
||||||
(export)="onExport($event)"
|
(export)="onExport($event)"
|
||||||
|
(exportDrafts)="onExportDrafts($event)"
|
||||||
(import)="onImport()"
|
(import)="onImport()"
|
||||||
></gf-activities-table>
|
></gf-activities-table>
|
||||||
</div>
|
</div>
|
||||||
|
59
apps/client/src/app/services/ics/ics.service.ts
Normal file
59
apps/client/src/app/services/ics/ics.service.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { capitalize } from '@ghostfolio/common/helper';
|
||||||
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Type } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class IcsService {
|
||||||
|
private readonly ICS_DATE_FORMAT = 'yyyyMMdd';
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public transformActivitiesToIcsContent(
|
||||||
|
aActivities: Export['activities']
|
||||||
|
): string {
|
||||||
|
const header = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Ghostfolio//NONSGML v1.0//EN'
|
||||||
|
];
|
||||||
|
const events = aActivities.map((activity) => {
|
||||||
|
return this.getEvent({
|
||||||
|
date: parseISO(activity.date),
|
||||||
|
id: activity.id,
|
||||||
|
symbol: activity.symbol,
|
||||||
|
type: activity.type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const footer = ['END:VCALENDAR'];
|
||||||
|
|
||||||
|
return [...header, ...events, ...footer].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEvent({
|
||||||
|
date,
|
||||||
|
id,
|
||||||
|
symbol,
|
||||||
|
type
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
id: string;
|
||||||
|
symbol: string;
|
||||||
|
type: Type;
|
||||||
|
}) {
|
||||||
|
const today = format(new Date(), this.ICS_DATE_FORMAT);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${id}`,
|
||||||
|
`DTSTAMP:${today}T000000`,
|
||||||
|
`DTSTART;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
|
||||||
|
`DTEND;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
|
||||||
|
`SUMMARY:${capitalize(type)} ${symbol}`,
|
||||||
|
'END:VEVENT'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
}
|
@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
|
|||||||
return Buffer.from(encodedDataSource, 'hex').toString();
|
return Buffer.from(encodedDataSource, 'hex').toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadAsFile(
|
export function downloadAsFile({
|
||||||
aContent: unknown,
|
content,
|
||||||
aFileName: string,
|
contentType = 'text/plain',
|
||||||
aContentType: string
|
fileName,
|
||||||
) {
|
format
|
||||||
|
}: {
|
||||||
|
content: unknown;
|
||||||
|
contentType?: string;
|
||||||
|
fileName: string;
|
||||||
|
format: 'json' | 'string';
|
||||||
|
}) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
|
|
||||||
type: aContentType
|
if (format === 'json') {
|
||||||
|
content = JSON.stringify(content, undefined, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new Blob([<string>content], {
|
||||||
|
type: contentType
|
||||||
});
|
});
|
||||||
a.href = URL.createObjectURL(file);
|
a.href = URL.createObjectURL(file);
|
||||||
a.download = aFileName;
|
a.download = fileName;
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,5 +5,14 @@ export interface Export {
|
|||||||
date: string;
|
date: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
activities: Partial<Order>[];
|
activities: (Omit<
|
||||||
|
Order,
|
||||||
|
| 'accountUserId'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'date'
|
||||||
|
| 'isDraft'
|
||||||
|
| 'symbolProfileId'
|
||||||
|
| 'updatedAt'
|
||||||
|
| 'userId'
|
||||||
|
> & { date: string; symbol: string })[];
|
||||||
}
|
}
|
||||||
|
@ -356,11 +356,22 @@
|
|||||||
*ngIf="hasPermissionToExportActivities"
|
*ngIf="hasPermissionToExportActivities"
|
||||||
class="align-items-center d-flex"
|
class="align-items-center d-flex"
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
|
[disabled]="dataSource.data.length === 0"
|
||||||
(click)="onExport()"
|
(click)="onExport()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
||||||
<span i18n>Export</span>
|
<span i18n>Export</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="hasPermissionToExportActivities"
|
||||||
|
class="align-items-center d-flex"
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="!hasDrafts"
|
||||||
|
(click)="onExportDrafts()"
|
||||||
|
>
|
||||||
|
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
|
||||||
|
<span i18n>Export Drafts as ICS</span>
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
@ -56,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
|
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
|
||||||
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
|
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
|
||||||
@Output() export = new EventEmitter<string[]>();
|
@Output() export = new EventEmitter<string[]>();
|
||||||
|
@Output() exportDrafts = new EventEmitter<string[]>();
|
||||||
@Output() import = new EventEmitter<void>();
|
@Output() import = new EventEmitter<void>();
|
||||||
|
|
||||||
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||||
@ -68,6 +69,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
public endOfToday = endOfToday();
|
public endOfToday = endOfToday();
|
||||||
public filters$: Subject<string[]> = new BehaviorSubject([]);
|
public filters$: Subject<string[]> = new BehaviorSubject([]);
|
||||||
public filters: Observable<string[]> = this.filters$.asObservable();
|
public filters: Observable<string[]> = this.filters$.asObservable();
|
||||||
|
public hasDrafts = false;
|
||||||
public isAfter = isAfter;
|
public isAfter = isAfter;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public isUUID = isUUID;
|
public isUUID = isUUID;
|
||||||
@ -198,6 +200,18 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onExportDrafts() {
|
||||||
|
this.exportDrafts.emit(
|
||||||
|
this.dataSource.filteredData
|
||||||
|
.filter((activity) => {
|
||||||
|
return activity.isDraft;
|
||||||
|
})
|
||||||
|
.map((activity) => {
|
||||||
|
return activity.id;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public onImport() {
|
public onImport() {
|
||||||
this.import.emit();
|
this.import.emit();
|
||||||
}
|
}
|
||||||
@ -234,6 +248,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
this.filters$.next(this.allFilters);
|
this.filters$.next(this.allFilters);
|
||||||
|
|
||||||
|
this.hasDrafts = this.dataSource.data.some((activity) => {
|
||||||
|
return activity.isDraft === true;
|
||||||
|
});
|
||||||
this.totalFees = this.getTotalFees();
|
this.totalFees = this.getTotalFees();
|
||||||
this.totalValue = this.getTotalValue();
|
this.totalValue = this.getTotalValue();
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user