From 8526b5a0279ef1a9e538695f920a45978cee80f4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 Apr 2022 10:53:40 +0200 Subject: [PATCH] Feature/export draft activities as ics (#830) * Export draft activities as ICS * Update changelog --- CHANGELOG.md | 6 ++ apps/api/src/app/export/export.service.ts | 4 +- .../position-detail-dialog.component.ts | 10 ++-- .../transactions-page.component.ts | 34 +++++++++-- .../transactions/transactions-page.html | 1 + .../src/app/services/ics/ics.service.ts | 59 +++++++++++++++++++ libs/common/src/lib/helper.ts | 27 ++++++--- .../src/lib/interfaces/export.interface.ts | 11 +++- .../activities-table.component.html | 11 ++++ .../activities-table.component.ts | 17 ++++++ 10 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 apps/client/src/app/services/ics/ics.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbf1dd57..2eff35556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Changed diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 007429a38..74cdf14f1 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -42,6 +42,7 @@ export class ExportService { accountId, date, fee, + id, quantity, SymbolProfile, type, @@ -49,13 +50,14 @@ export class ExportService { }) => { return { accountId, - date, fee, + id, quantity, type, unitPrice, currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, + date: date.toISOString(), symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol }; } diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index db4cbf471..55efc0249 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts @@ -211,14 +211,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit { ) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data) => { - downloadAsFile( - data, - `ghostfolio-export-${this.SymbolProfile?.symbol}-${format( + downloadAsFile({ + content: data, + fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format( parseISO(data.meta.date), 'yyyyMMddHHmm' )}.json`, - 'text/plain' - ); + format: 'json' + }); }); } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index ba73739b7..61683fd75 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -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 { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; 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 { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; @@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, + private icsService: IcsService, private impersonationStorageService: ImpersonationStorageService, private importTransactionsService: ImportTransactionsService, private route: ActivatedRoute, @@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .fetchExport(activityIds) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data) => { - downloadAsFile( - data, - `ghostfolio-export-${format( + for (const activity of data.activities) { + delete activity.id; + } + + downloadAsFile({ + content: data, + fileName: `ghostfolio-export-${format( parseISO(data.meta.date), 'yyyyMMddHHmm' )}.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' + }); }); } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html index 0df0171b9..31d29b1df 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html @@ -15,6 +15,7 @@ (activityToClone)="onCloneTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)" (export)="onExport($event)" + (exportDrafts)="onExportDrafts($event)" (import)="onImport()" > diff --git a/apps/client/src/app/services/ics/ics.service.ts b/apps/client/src/app/services/ics/ics.service.ts new file mode 100644 index 000000000..7329ea663 --- /dev/null +++ b/apps/client/src/app/services/ics/ics.service.ts @@ -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'); + } +} diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 351643732..ad47abfdd 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) { return Buffer.from(encodedDataSource, 'hex').toString(); } -export function downloadAsFile( - aContent: unknown, - aFileName: string, - aContentType: string -) { +export function downloadAsFile({ + content, + contentType = 'text/plain', + fileName, + format +}: { + content: unknown; + contentType?: string; + fileName: string; + format: 'json' | 'string'; +}) { 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([content], { + type: contentType }); a.href = URL.createObjectURL(file); - a.download = aFileName; + a.download = fileName; a.click(); } diff --git a/libs/common/src/lib/interfaces/export.interface.ts b/libs/common/src/lib/interfaces/export.interface.ts index 48ddd2c98..37dbfba79 100644 --- a/libs/common/src/lib/interfaces/export.interface.ts +++ b/libs/common/src/lib/interfaces/export.interface.ts @@ -5,5 +5,14 @@ export interface Export { date: string; version: string; }; - activities: Partial[]; + activities: (Omit< + Order, + | 'accountUserId' + | 'createdAt' + | 'date' + | 'isDraft' + | 'symbolProfileId' + | 'updatedAt' + | 'userId' + > & { date: string; symbol: string })[]; } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index cbcb7da9c..7cbfffe05 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -356,11 +356,22 @@ *ngIf="hasPermissionToExportActivities" class="align-items-center d-flex" mat-menu-item + [disabled]="dataSource.data.length === 0" (click)="onExport()" > Export + diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 52bc841ff..98ca9e2bd 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -56,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @Output() activityToClone = new EventEmitter(); @Output() activityToUpdate = new EventEmitter(); @Output() export = new EventEmitter(); + @Output() exportDrafts = new EventEmitter(); @Output() import = new EventEmitter(); @ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @@ -68,6 +69,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { public endOfToday = endOfToday(); public filters$: Subject = new BehaviorSubject([]); public filters: Observable = this.filters$.asObservable(); + public hasDrafts = false; public isAfter = isAfter; public isLoading = true; 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() { this.import.emit(); } @@ -234,6 +248,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { this.filters$.next(this.allFilters); + this.hasDrafts = this.dataSource.data.some((activity) => { + return activity.isDraft === true; + }); this.totalFees = this.getTotalFees(); this.totalValue = this.getTotalValue(); }