Compare commits

..

54 Commits

Author SHA1 Message Date
3fe8f9c882 Release 1.165.0 (#1044) 2022-06-25 17:34:06 +02:00
d130efad47 Clean up comments (#1043)
* Clean up comments
2022-06-25 17:30:43 +02:00
109f0ebd70 Feature/move positions table to holdings section (#1042)
* Move positions table to holdings section

* Update changelog
2022-06-25 14:47:20 +02:00
069ddcc6b2 Feature/add reusable premium indicator component (#1041)
* Add premium indicator component

* Update changelog
2022-06-25 12:38:15 +02:00
f7bf6e652b Feature/add icon and name to positions table (#1040)
* Add icon and name

* Update changelog
2022-06-25 11:33:59 +02:00
eb059a024a Feature/delete data in data gathering by symbol (#1039)
* Delete market data

* Update changelog
2022-06-25 11:15:01 +02:00
ad88acff1c Release 1.164.0 (#1038) 2022-06-23 19:25:46 +02:00
1ff736537c Feature/add positions table to public page (#1037)
* Add positions table

* Update changelog
2022-06-23 19:24:24 +02:00
1fa65e1efd Release 1.163.0 (#1036) 2022-06-22 20:32:06 +02:00
df6bb489c2 Feature/improve onboarding for ios (#1035)
* Improve onboarding for iOS

* Update changelog
2022-06-22 20:30:03 +02:00
928a13310d Improve title (#1031) 2022-06-21 17:58:13 +02:00
2384861953 Remove experimental (#1032) 2022-06-20 20:32:51 +02:00
fe90bda6fb Release 1.162.0 (#1029) 2022-06-18 17:48:54 +02:00
d4b29ff11c Feature/add privacy policy page (#1028)
* Add privacy policy page

* Update changelog
2022-06-18 17:46:51 +02:00
a0a26cfa58 Feature/simplify header (#1027)
* Hide pricing page link for Premium users

* Harmonize content

* Update changelog
2022-06-18 11:57:27 +02:00
1610150427 Bugfix/fix currency conversion of ila to ils (#1026)
* Fix currency conversion: ILA to ILS

* Update changelog
2022-06-17 20:34:41 +02:00
cff8acd7b1 Clean up (#1022) 2022-06-17 20:04:52 +02:00
0f36d6cbdb Release 1.161.1 (#1025) 2022-06-16 17:20:44 +02:00
046e28b521 Release 1.161.0 (#1024) 2022-06-16 16:51:57 +02:00
aba562cb35 Bugfix/fix error handling for missing market prices (#1023)
* Add fallback for missing market price

* Update changelog
2022-06-16 16:49:29 +02:00
03f2f33344 Feature/restructure landing page (#1021)
* Restructure landing page

* Update changelog
2022-06-16 16:29:35 +02:00
a996dd7ed5 Feature/add vertical hover line to performance chart (#1020)
* Add vertical hover line

* Update changelog
2022-06-16 14:16:53 +02:00
002b883668 Feature/upgrade to angular 14 (#1019)
* Upgrade to angular 14

* Migrate UntypedFormControl to FormControl

* Update changelog
2022-06-16 10:28:23 +02:00
0b06823893 Release 1.160.0 (#1018) 2022-06-15 20:18:13 +02:00
2dfd779444 Bugfix/fix no data provider has been found in search (#1017)
* Fix default value of DATA_SOURCES

* Update changelog
2022-06-15 20:16:38 +02:00
1824413379 Release 1.159.0 (#1015) 2022-06-15 13:24:07 +02:00
3332ade3d3 Feature/filter public endpoint by equity asset class (#1014)
* Filter by ASSET_CLASS: EQUITY

* Update changelog
2022-06-15 13:21:47 +02:00
8d2e110e3d Feature/change default host (#1013)
* Change default host to 0.0.0.0

* Update changelog
2022-06-14 17:43:42 +02:00
a8fcf09380 Fix link (#1012) 2022-06-13 17:44:55 +02:00
1071f446a8 Release 1.158.1 (#1010) 2022-06-12 19:21:02 +02:00
03b050d1ac Release 1.158.0 (#1009) 2022-06-12 17:53:09 +02:00
58eeff7001 Feature/expose the environment variable host (#1007)
* Expose environment variable: HOST

* Update changelog
2022-06-12 17:51:26 +02:00
76fb8825e4 Feature/restructure self hosting documentation (#1008)
* Restructure self-hosting documentation

* Update changelog
2022-06-12 17:50:45 +02:00
0f9d142afe Feature/decrease number of attempts of queue jobs (#1006)
* Decrease number of attempts

* Update changelog
2022-06-12 17:39:55 +02:00
bd33855a27 Feature/improve message for data provider errors (#1003)
* Add title

* Update changelog
2022-06-12 17:39:02 +02:00
5329e45e2c Feature/add data dialog to queue jobs view (#1005)
* Add data dialog

* Update changelog
2022-06-12 17:30:26 +02:00
e990ecd12c Feature/improve cash balance label (#1002)
* Improve label

* Update changelog
2022-06-12 10:06:18 +02:00
a4fcf64f13 Release 1.157.0 (#1000) 2022-06-11 13:41:39 +02:00
557e3a0676 Feature/migrate historical market data gathering to queue design pattern (#991)
* Migrate historical market data gathering to queue

* Filter and delete jobs

* Detect duplicate jobs

* Update changelog
2022-06-11 13:40:15 +02:00
2abe399ebd Bugfix/force reload accounts of user after change (#994)
* Force reload of accounts after change

* Update changelog
2022-06-11 12:56:34 +02:00
74fe90906a Bugfix/exclude emtpy items in activities filter (#999)
* Exclude empty items

* Update changelog
2022-06-11 12:55:32 +02:00
4cb9a3b142 Feature/refresh cryptocurrencies list (#998)
* Use cryptocurrencies list instead of outdated npm package

* Update changelog
2022-06-11 12:54:58 +02:00
0da9368e0c Fix date (#992) 2022-06-10 17:42:56 +02:00
d2f8e3d645 Feature/increase fear and greed index to 180 days (#993)
* Increase number of days to 180

* Update changelog
2022-06-09 18:56:29 +02:00
5263fba64e Feature/upgrade envalid to version 7.3.1 (#990)
* Upgrade envalid to version 7.3.1

* Update changelog
2022-06-06 17:42:32 +02:00
e3689c48f8 Feature/upgrade chart.js to version 3.8.0 (#989)
* Upgrade chart.js to version 3.8.0

* Update changelog
2022-06-06 08:27:34 +02:00
787efdb33b Release 1.156.0 (#988) 2022-06-05 19:03:11 +02:00
e63578d8ce Feature/add guards to local comparison (#986)
* Add guards

* Improve labels
2022-06-05 19:00:45 +02:00
7cf0cdc4ce Feature/add jobs of queue to admin control panel (#987)
* Add jobs of queue to admin control panel

* Update changelog
2022-06-05 19:00:20 +02:00
14a0eeab29 Bugfix/fix docker compose files to resolve variables correctly (#983)
* Fix variable resolving

* Update changelog
2022-06-04 10:02:38 +02:00
6774c48dff Feature/restructure fire page (#982)
* Restructure fire page

* Update changelog
2022-06-04 09:06:53 +02:00
565947e752 Feature/upgrade simplewebauthn browser and server to version 5.2.1 (#981)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server

* Update changelog
2022-06-03 18:44:53 +02:00
2cc7c6fa1c Feature/add user id to account page (#980)
* Add user id

* Update changelog
2022-06-03 06:51:08 +02:00
023a7147e2 Feature/simplify feature page (#978)
* Simplify page

* Update changelog
2022-05-31 18:01:29 +02:00
164 changed files with 14886 additions and 6988 deletions

1
.env
View File

@ -14,4 +14,3 @@ ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
PORT=3333

View File

@ -5,6 +5,124 @@ 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).
## 1.165.0 - 25.06.2022
### Added
- Added an icon and name column to the positions table
- Added a reusable premium indicator component
### Changed
- Moved the positions table to a dedicated section (_Holdings_)
- Changed the data gathering by symbol endpoint to delete data first
## 1.164.0 - 23.06.2022
### Added
- Added the positions table including performance to the public page
## 1.163.0 - 22.06.2022
### Changed
- Improved the onboarding for iOS
## 1.162.0 - 18.06.2022
### Added
- Added a _Privacy Policy_ page
### Changed
- Simplified the header
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
## 1.161.1 - 16.06.2022
### Added
- Added the vertical hover line to inspect data points in the performance chart on the home page
### Changed
- Improved the landing page
- Upgraded `angular` from version `13.3.6` to `14.0.2`
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
### Fixed
- Improved the error handling of missing market prices
## 1.160.0 - 15.06.2022
### Fixed
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
## 1.159.0 - 15.06.2022
### Changed
- Changed the default `HOST` to `0.0.0.0`
- Refactored the endpoint of the public page (filter by equity)
## 1.158.1 - 12.06.2022
### Added
- Extended the queue jobs view in the admin control panel by a data dialog
### Changed
- Exposed the environment variable `HOST`
- Decreased the number of attempts of queue jobs from `20` to `10` (fail earlier)
- Improved the message for data provider errors in the client
- Changed the label from _Balance_ to _Cash Balance_ in the account dialog
- Restructured the documentation for self-hosting
## 1.157.0 - 11.06.2022
### Added
- Extended the queue jobs view in the admin control panel by the number of attempts and the status
### Changed
- Migrated the historical market data gathering to the queue design pattern
- Refreshed the cryptocurrencies list to support more coins by default
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 180 days
- Upgraded `chart.js` from version `3.7.0` to `3.8.0`
- Upgraded `envalid` from version `7.2.1` to `7.3.1`
### Fixed
- Reloaded the accounts of a user after creating, editing or deleting one
- Excluded empty items in the activities filter
## 1.156.0 - 05.06.2022
### Added
- Added the user id to the account page
- Added a new view with jobs of the queue to the admin control panel
### Changed
- Simplified the features page
- Restructured the _FIRE_ section
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
### Fixed
- Fixed the `docker-compose` files to resolve variables correctly
## 1.155.0 - 29.05.2022
### Added

View File

@ -22,7 +22,7 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs

View File

@ -35,7 +35,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
## Why Ghostfolio?
@ -79,47 +79,50 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Run with Docker (self-hosting)
## Self-hosting
### Prerequisites
### Run with Docker Compose
- [Docker](https://www.docker.com/products/docker-desktop)
- A local copy of this Git repository (clone)
#### Prerequisites
### a. Run environment
- Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone)
#### a. Run environment
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash
docker-compose -f docker/docker-compose.yml up -d
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
```
#### Setup Database
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment
#### b. Build and run environment
Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up -d
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
```
#### Setup Database
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
@ -127,13 +130,13 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Upgrade Version
#### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting)
### Run with _Unraid_ (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
@ -149,7 +152,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Setup
1. Run `yarn install`
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
@ -183,7 +186,7 @@ yarn database:push
Run `yarn test`
## Public API (experimental)
## Public API
### Import Activities

View File

@ -2,6 +2,7 @@
"version": 1,
"projects": {
"api": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
@ -56,6 +57,7 @@
"tags": []
},
"client": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
@ -189,6 +191,7 @@
"tags": []
},
"client-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
@ -211,6 +214,7 @@
"implicitDependencies": ["client"]
},
"common": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
@ -233,6 +237,7 @@
"tags": []
},
"ui": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
@ -258,14 +263,12 @@
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"builder": "@storybook/angular:start-storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
},
"projectBuildConfig": "ui:build-storybook"
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
@ -274,15 +277,13 @@
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"builder": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
},
"projectBuildConfig": "ui:build-storybook"
"outputDir": "dist/storybook/ui",
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
@ -294,6 +295,7 @@
"tags": []
},
"ui-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",

View File

@ -1,4 +1,4 @@
module.exports = {
export default {
displayName: 'api',
globals: {
@ -13,5 +13,5 @@ module.exports = {
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node',
preset: '../../jest.preset.ts'
preset: '../../jest.preset.js'
};

View File

@ -2,8 +2,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import {
AdminData,
@ -12,7 +12,6 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import {
Body,
Controller,
@ -28,7 +27,6 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -39,8 +37,6 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController {
public constructor(
private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -64,6 +60,24 @@ export class AdminController {
return this.adminService.get();
}
@Post('gather')
@UseGuards(AuthGuard('jwt'))
public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days();
}
@Post('gather/max')
@UseGuards(AuthGuard('jwt'))
public async gatherMax(): Promise<void> {
@ -82,10 +96,14 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
this.dataGatheringService.gatherMax();
@ -109,10 +127,14 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
}
@ -134,10 +156,14 @@ export class AdminController {
);
}
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
@Post('gather/:dataSource/:symbol')

View File

@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { QueueModule } from './queue/queue.module';
@Module({
imports: [
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
MarketDataModule,
PrismaModule,
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule
],

View File

@ -42,8 +42,6 @@ export class AdminService {
public async get(): Promise<AdminData> {
return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
@ -60,7 +58,6 @@ export class AdminService {
)
};
}),
lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
@ -161,30 +158,11 @@ export class AdminService {
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
if (lastDataGathering) {
return lastDataGathering;
}
const dataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (dataGatheringInProgress) {
return 'IN_PROGRESS';
}
return undefined;
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: {

View File

@ -0,0 +1,87 @@
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service';
@Controller('admin/queue')
export class QueueController {
public constructor(
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete('job')
@UseGuards(AuthGuard('jwt'))
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status });
}
@Get('job')
@UseGuards(AuthGuard('jwt'))
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status });
}
@Delete('job/:id')
@UseGuards(AuthGuard('jwt'))
public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id);
}
}

View File

@ -0,0 +1,12 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller';
import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule],
providers: [QueueService]
})
export class QueueModule {}

View File

@ -0,0 +1,65 @@
import {
DATA_GATHERING_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue
) {}
public async deleteJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.remove();
}
public async deleteJobs({
status = QUEUE_JOB_STATUS_LIST
}: {
status?: JobStatus[];
}) {
const jobs = await this.dataGatheringQueue.getJobs(status);
for (const job of jobs) {
try {
await job.remove();
} catch (error) {
Logger.warn(error, 'QueueService');
}
}
}
public async getJobs({
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {
jobs: jobsWithState
};
}
}

View File

@ -1,26 +1,6 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller()
export class AppController {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
private async initialize() {
this.redisCacheService.reset();
const isDataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (isDataGatheringInProgress) {
// Prepare for automatical data gathering, if hung up in progress state
await this.dataGatheringService.reset();
}
}
public constructor() {}
}

View File

@ -1,25 +1,39 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import {
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache')
export class CacheController {
public constructor(
private readonly cacheService: CacheService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {
this.redisCacheService.reset();
}
) {}
@Post('flush')
@UseGuards(AuthGuard('jwt'))
public async flushCache(): Promise<void> {
this.redisCacheService.reset();
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.cacheService.flush();
return this.redisCacheService.reset();
}
}

View File

@ -1,4 +1,3 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller';
@Module({
exports: [CacheService],
controllers: [CacheController],
imports: [
ConfigurationModule,
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
PrismaModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [CacheService]
]
})
export class CacheModule {}

View File

@ -1,15 +0,0 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CacheService {
public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(): Promise<void> {
await this.dataGaterhingService.reset();
return;
}
}

View File

@ -106,7 +106,6 @@ export class InfoService {
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
@ -215,13 +214,6 @@ export class InfoService {
});
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
return lastDataGathering ?? null;
}
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;

View File

@ -1,16 +1,14 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import {
AssetClass,
@ -21,7 +19,6 @@ import {
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@ -32,11 +29,8 @@ import { Activity } from './interfaces/activities.interface';
export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -120,10 +114,14 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
});
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
const isDraft = isAfter(data.date as Date, endOfToday());
@ -138,8 +136,6 @@ export class OrderService {
]);
}
await this.cacheService.flush();
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
@ -330,8 +326,6 @@ export class OrderService {
}
}
await this.cacheService.flush();
delete data.assetClass;
delete data.assetSubClass;
delete data.currency;

View File

@ -56,7 +56,7 @@ export class PortfolioCalculator {
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
this.orders.sort((a, b) => a.date?.localeCompare(b.date));
}
public computeTransactionPoints() {
@ -125,7 +125,7 @@ export class PortfolioCalculator {
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,

View File

@ -316,7 +316,9 @@ export class PortfolioController {
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
access.userId,
'max',
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
);
const portfolioPublicDetails: PortfolioPublicDetails = {
@ -325,9 +327,6 @@ export class PortfolioController {
};
const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';
})
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
@ -338,17 +337,18 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
if (portfolioPosition.assetClass === 'EQUITY') {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
};
}
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
value: portfolioPosition.value / totalValue
};
}
return portfolioPublicDetails;

View File

@ -273,7 +273,6 @@ export class PortfolioService {
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber()
}));
@ -394,7 +393,7 @@ export class PortfolioService {
continue;
}
const value = item.quantity.mul(item.marketPrice);
const value = item.quantity.mul(item.marketPrice ?? 0);
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
@ -442,6 +441,7 @@ export class PortfolioService {
sectors: symbolProfile.sectors,
symbol: item.symbol,
transactionCount: item.transactionCount,
url: symbolProfile.url,
value: value.toNumber()
};
}
@ -658,7 +658,7 @@ export class PortfolioService {
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
quantity.mul(marketPrice ?? 0).toNumber(),
currency,
userCurrency
)

View File

@ -12,6 +12,7 @@ import {
} from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { sortBy } from 'lodash';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
@ -185,6 +186,9 @@ export class UserService {
}
}
user.Account = sortBy(user.Account, (account) => {
return account.name;
});
user.permissions = currentPermissions.sort();
return user;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
{
"LUNA1": "Terra",
"UNI1": "Uniswap"
}

View File

@ -20,10 +20,11 @@ async function bootstrap() {
})
);
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 3333;
await app.listen(port, () => {
await app.listen(port, host, () => {
logLogo();
Logger.log(`Listening at http://localhost:${port}`);
Logger.log(`Listening at http://${host}:${port}`);
Logger.log('');
});
}

View File

@ -15,7 +15,7 @@ export class ConfigurationService {
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
@ -31,12 +31,13 @@ export class ConfigurationService {
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),

View File

@ -1,11 +1,9 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -14,15 +12,13 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_MINUTE)
public async runEveryMinute() {
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
await this.dataGatheringService.gather7Days();
}
@ -41,10 +37,14 @@ export class CronService {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
}
}

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('cryptocurrencies');
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
@Injectable()
export class CryptocurrencyService {
@ -18,7 +17,7 @@ export class CryptocurrencyService {
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...cryptocurrencies.symbols(),
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies)
];
}

View File

@ -1,14 +0,0 @@
{
"1INCH": "1inch",
"ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche",
"DOT": "Polkadot",
"LUNA1": "Terra",
"MATIC": "Polygon",
"MINA": "Mina Protocol",
"RUNE": "THORChain",
"SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"
}

View File

@ -6,20 +6,27 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { MarketDataModule } from './market-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [
BullModule.registerQueue({
limiter: {
duration: ms('5 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule
],

View File

@ -1,19 +1,34 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import {
format,
getDate,
getMonth,
getYear,
isBefore,
parseISO
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
@ -21,7 +36,93 @@ export class DataGatheringProcessor {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
Logger.error(
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
throw new Error(error);
}
}
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
parseISO(<string>(<unknown>date)),
new Date()
);
let currentDate = parseISO(<string>(<unknown>date));
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
Logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
throw new Error(error);
}
}
}

View File

@ -1,191 +1,72 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING
DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
format,
getDate,
getMonth,
getYear,
isBefore,
subDays
} from 'date-fns';
import { JobOptions, Queue } from 'bull';
import { format, subDays } from 'date-fns';
import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service';
@Injectable()
export class DataGatheringService {
private dataGatheringProgress: number;
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async gather7Days() {
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) {
Logger.log('7d data gathering has been started.', 'DataGatheringService');
console.time('data-gathering-7d');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbols7D();
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
const hasJob = await this.hasJob(name, data);
if (hasJob) {
Logger.log(
'7d data gathering has been completed.',
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-7d');
} else {
return this.dataGatheringQueue.add(name, data, options);
}
}
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems);
}
public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
Logger.log(
'Max data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-max');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbolsMax();
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(
'Max data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-max');
}
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols(dataGatheringItems);
}
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
await this.marketDataService.deleteMany({ dataSource, symbol });
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
});
if (!isDataGatheringLocked) {
Logger.log(
`Symbol data gathering for ${symbol} has been started.`,
'DataGatheringService'
);
console.time('data-gathering-symbol');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(
`Symbol data gathering for ${symbol} has been completed.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-symbol');
}
await this.gatherSymbols(symbols);
}
public async gatherSymbolForDate({
@ -235,15 +116,6 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets();
}
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets
);
@ -334,136 +206,21 @@ export class DataGatheringService {
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
dataSource,
date,
new Date()
);
let currentDate = date;
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
currentDate,
DATE_FORMAT
)}.`,
'DataGatheringService'
);
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
} catch (error) {
hasError = true;
Logger.error(error, 'DataGatheringService');
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`,
'DataGatheringService'
);
}
symbolCounter += 1;
symbol
},
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
);
}
await this.exchangeRateDataService.initialize();
if (hasError) {
throw '';
}
}
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() {
return await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
}
public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
return undefined;
}
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
@ -534,19 +291,6 @@ export class DataGatheringService {
});
}
public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService');
await this.prismaService.property.deleteMany({
where: {
OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
}
});
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
@ -610,15 +354,17 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering();
private async hasJob(name: string, data: any) {
const jobs = await this.dataGatheringQueue.getJobs(
QUEUE_JOB_STATUS_LIST.filter((status) => {
return status !== 'completed';
})
);
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
return jobs.some((job) => {
return (
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
);
});
const diffInHours = differenceInHours(new Date(), lastDataGathering);
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
}
}

View File

@ -9,7 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { format, isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -76,9 +76,12 @@ export class AlphaVantageService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, 'AlphaVantageService');
return {};
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}

View File

@ -72,10 +72,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
{ [aSymbol]: {} }
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
return {};
}
public getName(): DataSource {

View File

@ -87,10 +87,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
};
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
return {};
}
public getName(): DataSource {

View File

@ -71,10 +71,13 @@ export class GoogleSheetsService implements DataProviderInterface {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error, 'GoogleSheetsService');
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
return {};
}
public getName(): DataSource {

View File

@ -90,7 +90,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
}
};
}
} catch (error) {}
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
return {};
}

View File

@ -131,7 +131,13 @@ export class YahooFinanceService implements DataProviderInterface {
if (url) {
response.url = url;
}
} catch {}
} catch (error) {
throw new Error(
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
error.name
}] ${error.message}`
);
}
return response;
}
@ -175,6 +181,9 @@ export class YahooFinanceService implements DataProviderInterface {
if (symbol === 'USDGBp') {
// Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === 'USDILA') {
// Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber();
}
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
@ -185,12 +194,12 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
Logger.warn(
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
'YahooFinanceService'
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
return {};
}
}
@ -237,6 +246,18 @@ export class YahooFinanceService implements DataProviderInterface {
.mul(100)
.toNumber()
};
} else if (
symbol === 'USDILS' &&
yahooFinanceSymbols.includes('USDILA=X')
) {
// Convert ILS to ILA
response['USDILA'] = {
...response[symbol],
currency: 'ILA',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
};
}
}

View File

@ -6,7 +6,7 @@ export interface Environment extends CleanedEnvAccessors {
BASE_CURRENCY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;

View File

@ -1,3 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import {
Account,
@ -32,8 +33,6 @@ export interface IDataProviderResponse {
marketState: MarketState;
}
export interface IDataGatheringItem {
dataSource: DataSource;
export interface IDataGatheringItem extends UniqueAsset {
date?: Date;
symbol: string;
}

View File

@ -1,4 +1,4 @@
module.exports = {
export default {
displayName: 'client',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
@ -18,5 +18,5 @@ module.exports = {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
preset: '../../jest.preset.js'
};

View File

@ -5,9 +5,6 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,

View File

@ -16,6 +16,13 @@ const routes: Routes = [
(m) => m.ChangelogPageModule
)
},
{
path: 'about/privacy-policy',
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
{
path: 'account',
loadChildren: () =>
@ -120,6 +127,13 @@ const routes: Routes = [
(m) => m.FirePageModule
)
},
{
path: 'portfolio/holdings',
loadChildren: () =>
import('./pages/portfolio/holdings/holdings-page.module').then(
(m) => m.HoldingsPageModule
)
},
{
path: 'portfolio/report',
loadChildren: () =>

View File

@ -0,0 +1,110 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { JobStatus } from 'bull';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-jobs',
styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html'
})
export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = [];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private formBuilder: FormBuilder,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale
);
}
});
}
public ngOnInit() {
this.filterForm = this.formBuilder.group({
status: []
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
this.fetchJobs();
}
public onDeleteJob(aId: string) {
this.adminService
.deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchJobs();
});
}
public onDeleteJobs() {
const currentFilter = this.filterForm.get('status').value;
this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
alert(JSON.stringify(aData, null, ' '));
}
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
alert(JSON.stringify(aStacktrace, null, ' '));
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchJobs(aStatus?: JobStatus[]) {
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.jobs = jobs;
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -0,0 +1,130 @@
<div class="container">
<div class="row">
<div class="col">
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="flex-grow-1">
<mat-select formControlName="status">
<mat-option></mat-option>
<mat-option
*ngFor="let statusFilterOption of statusFilterOptions"
[value]="statusFilterOption"
>{{ statusFilterOption }}</mat-option
>
</mat-select>
</mat-form-field>
<button
class="ml-1"
color="warn"
mat-flat-button
(click)="onDeleteJobs()"
>
<span i18n>Delete Jobs</span>
</button>
</form>
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onViewData(job.data)">
View Data
</button>
<button
i18n
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
View Stacktrace
</button>
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
Delete Job
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { AdminJobsComponent } from './admin-jobs.component';
@NgModule({
declarations: [AdminJobsComponent],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatMenuModule,
MatSelectModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminJobsModule {}

View File

@ -0,0 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -31,9 +31,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@ -53,9 +50,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminMarketData();
}

View File

@ -15,7 +15,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
differenceInSeconds,
formatDistanceToNowStrict,
isValid,
parseISO
} from 'date-fns';
import { uniq } from 'lodash';
@ -32,23 +31,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public coupons: Coupon[];
public customCurrencies: string[];
public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public lastDataGathering: string;
public transactionCount: number;
public userCount: number;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
@ -82,9 +75,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
}
@ -128,7 +118,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');
if (confirmation) {
if (confirmation === true) {
const coupons = this.coupons.filter((coupon) => {
return coupon.code !== aCouponCode;
});
@ -139,7 +129,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
if (confirmation) {
if (confirmation === true) {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
@ -152,8 +142,23 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onFlushCache() {
this.cacheService
.flush()
const confirmation = confirm('Do you really want to flush the cache?');
if (confirmation === true) {
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
@ -163,20 +168,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onGatherMax() {
const confirmation = confirm(
'This action may take some time. Do you want to proceed?'
);
if (confirmation === true) {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
@ -207,39 +206,15 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.dataService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
dataGatheringProgress,
exchangeRates,
lastDataGathering,
settings,
transactionCount,
userCount
}) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates;
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.transactionCount = transactionCount;
this.userCount = userCount;
if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNowStrict(
new Date(lastDataGathering),
{
addSuffix: true
}
);
} else if (lastDataGathering === 'IN_PROGRESS') {
this.dataGatheringInProgress = true;
} else {
this.lastDataGathering = 'Starting soon...';
}
this.transactionCount = transactionCount;
this.userCount = userCount;
this.changeDetectorRef.markForCheck();
}
);
this.changeDetectorRef.markForCheck();
});
}
private generateCouponCode(aLength: number) {

View File

@ -19,37 +19,30 @@
<div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div>
<div class="w-50">
<div>
<ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container
>
<ng-container *ngIf="dataGatheringInProgress" i18n
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
}})</ng-container
>
</div>
<div class="mt-2 overflow-hidden">
<div class="overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onFlushCache()"
(click)="onGather7Days()"
>
<ion-icon
class="mr-1"
name="close-circle-outline"
name="cloud-download-outline"
></ion-icon>
<span i18n>Reset Data Gathering</span>
<span i18n>Gather Recent Data</span>
</button>
</div>
<div class="mb-2">
<button
color="warn"
color="accent"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherMax()"
>
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
@ -58,7 +51,6 @@
class="mb-2 mr-2"
color="accent"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherProfileData()"
>
<ion-icon
@ -97,7 +89,6 @@
*ngIf="customCurrencies.includes(exchangeRate.label2)"
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<ion-icon name="trash-outline"></ion-icon>
@ -109,7 +100,6 @@
<button
color="primary"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onAddCurrency()"
>
<ion-icon class="mr-1" name="add-outline"></ion-icon>
@ -126,7 +116,6 @@
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteSystemMessage()"
>
<ion-icon name="trash-outline"></ion-icon>
@ -197,6 +186,15 @@
</div>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Housekeeping</div>
<div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
<span i18n>Flush Cache</span>
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>

View File

@ -21,9 +21,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -38,9 +35,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
}

View File

@ -35,11 +35,10 @@
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>
<ion-icon
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
class="ml-1"
></gf-premium-indicator>
</div>
</td>
<td class="mat-cell px-1 py-2 text-right">

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component';
@ -9,7 +10,13 @@ import { AdminUsersComponent } from './admin-users.component';
@NgModule({
declarations: [AdminUsersComponent],
exports: [],
imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
imports: [
CommonModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatMenuModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminUsersModule {}

View File

@ -66,7 +66,9 @@
>Resources</a
>
<a
*ngIf="hasPermissionForSubscription"
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
@ -203,7 +205,9 @@
>Resources</a
>
<a
*ngIf="hasPermissionForSubscription"
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-block d-sm-none"
i18n
mat-menu-item
@ -229,13 +233,7 @@
mat-button
[routerLink]="['/']"
>
<gf-logo
[hideName]="
!currentRoute ||
currentRoute === 'register' ||
currentRoute === 'start'
"
></gf-logo>
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
</a>
<span class="spacer"></span>
<a

View File

@ -36,9 +36,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -81,9 +78,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;

View File

@ -25,14 +25,11 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public historicalData: HistoricalDataItem[];
public info: InfoItem;
public isLoading = true;
public readonly numberOfDays = 90;
public readonly numberOfDays = 180;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -89,9 +86,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {}
public ngOnDestroy() {

View File

@ -42,9 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -69,9 +66,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;

View File

@ -4,7 +4,9 @@
<div class="row w-100">
<div class="chart-container col">
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"

View File

@ -25,10 +25,8 @@
gf-line-chart {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -1;
}
}
}

View File

@ -21,9 +21,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -46,9 +43,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()

View File

@ -81,9 +81,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
}
public onShowErrors() {
const errorMessageParts = this.errors.map((error) => {
return `${error.symbol} (${error.dataSource})`;
});
const errorMessageParts = ['Data Provider Errors for'];
for (const error of this.errors) {
errorMessageParts.push(`${error.symbol} (${error.dataSource})`);
}
alert(errorMessageParts.join('\n'));
}

View File

@ -6,12 +6,40 @@
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="icon">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-symbol-icon
*ngIf="element.url"
[tooltip]="element.name"
[url]="element.url"
></gf-symbol-icon>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
mat-sort-header
>
Name
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<ng-container *ngIf="element.name !== element.symbol">{{
element.name
}}</ng-container>
</td>
</ng-container>
@ -36,48 +64,6 @@
</td>
</ng-container>
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Performance
</th>
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.netPerformancePercent"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="allocationInvestment">
<th
*matHeaderCellDef
class="justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Initial Allocation
</th>
<td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end px-1">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.allocationInvestment"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="allocationCurrent">
<th
*matHeaderCellDef
@ -86,7 +72,7 @@
mat-header-cell
mat-sort-header
>
Current Allocation
Allocation
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
@ -99,15 +85,39 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Performance
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.netPerformancePercent"
></gf-value>
</div>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer': !ignoreAssetSubClasses.includes(row.assetSubClass)
'cursor-pointer':
hasPermissionToShowValues &&
!ignoreAssetSubClasses.includes(row.assetSubClass)
}"
(click)="
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
hasPermissionToShowValues &&
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
"
></tr>

View File

@ -27,7 +27,9 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToShowValues = true;
@Input() locale: string;
@Input() pageSize = Number.MAX_SAFE_INTEGER;
@Input() positions: PortfolioPosition[];
@Output() transactionDeleted = new EventEmitter<string>();
@ -44,7 +46,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
ASSET_SUB_CLASS_EMERGENCY_FUND
];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>();
@ -54,13 +55,14 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = [
'symbol',
'value',
'performance',
'allocationInvestment',
'allocationCurrent'
];
this.displayedColumns = ['icon', 'symbol', 'name'];
if (this.hasPermissionToShowValues) {
this.displayedColumns.push('value');
}
this.displayedColumns.push('allocationCurrent');
this.displayedColumns.push('performance');
this.isLoading = true;

View File

@ -35,7 +35,6 @@ import { PositionsTableComponent } from './positions-table.component';
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsTableModule {}

View File

@ -23,7 +23,7 @@ export class ToggleComponent implements OnChanges, OnInit {
@Output() change = new EventEmitter<Pick<ToggleOption, 'value'>>();
public option = new FormControl();
public option = new FormControl<string>(undefined);
public constructor() {}

View File

@ -17,6 +17,7 @@ export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = [
'/about',
'/about/changelog',
'/about/privacy-policy',
'/blog',
'/de/blog',
'/en/blog',

View File

@ -26,9 +26,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -54,9 +51,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.statistics = statistics;
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -4,11 +4,11 @@
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<div class="about-container">
<p>
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of stocks, ETFs or
cryptocurrencies and make solid, data-driven investment decisions. The
source code is fully available as open source software (OSS). The
project has been initiated by
Ghostfolio is a lightweight wealth management application for
individuals to keep track of stocks, ETFs or cryptocurrencies and make
solid, data-driven investment decisions. The source code is fully
available as open source software (OSS). The project has been
initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
@ -174,8 +174,8 @@
<div class="row">
<div
class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
class="col-md-4 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
@ -186,7 +186,17 @@
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"

View File

@ -10,9 +10,6 @@ import { Subject } from 'rxjs';
export class ChangelogPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
public ngOnDestroy() {

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
const routes: Routes = [
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
exports: [RouterModule],
imports: [RouterModule.forChild(routes)]
})
export class PrivacyPolicyPageRoutingModule {}

View File

@ -0,0 +1,19 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-privacy-policy-page',
styleUrls: ['./privacy-policy-page.scss'],
templateUrl: './privacy-policy-page.html'
})
export class PrivacyPolicyPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,8 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
<markdown [src]="'assets/privacy-policy.md'"></markdown>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { PrivacyPolicyPageRoutingModule } from './privacy-policy-page-routing.module';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
@NgModule({
declarations: [PrivacyPolicyPageComponent],
imports: [
CommonModule,
MarkdownModule.forChild(),
PrivacyPolicyPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PrivacyPolicyPageModule {}

View File

@ -0,0 +1,21 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
::ng-deep {
markdown {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -63,9 +63,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -145,9 +142,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;

View File

@ -16,12 +16,13 @@
<div class="pr-1 w-50" i18n>Membership</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex mb-1">
{{ user?.subscription?.type }}
<ion-icon
<a [routerLink]="['/pricing']"
>{{ user?.subscription?.type }}</a
>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
class="ml-1"
></gf-premium-indicator>
</div>
<div *ngIf="user?.subscription?.type === 'Premium'">
Valid until {{ user?.subscription?.expiresAt | date:
@ -54,11 +55,11 @@
class="mr-2 my-2"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span
><ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
><span i18n>Try Premium</span>
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></a>
<a
class="mr-2 my-2"
@ -169,6 +170,10 @@
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div>
</div>
</mat-card-content>
</mat-card>
</div>

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AccountPageRoutingModule } from './account-page-routing.module';
@ -25,6 +26,7 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
FormsModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatCardModule,

View File

@ -2,15 +2,6 @@
color: rgb(var(--dark-primary-text));
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
gf-access-table {
overflow-x: auto;

View File

@ -35,9 +35,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -67,9 +64,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
@ -133,6 +127,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
});
@ -179,6 +178,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
});
@ -220,6 +224,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
});

View File

@ -29,7 +29,7 @@
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Balance</mat-label>
<mat-label i18n>Cash Balance</mat-label>
<input
matInput
name="balance"

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminJobsComponent } from '@ghostfolio/client/components/admin-jobs/admin-jobs.component';
import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component';
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
@ -14,6 +15,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'jobs', component: AdminJobsComponent },
{ path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent }

View File

@ -16,18 +16,12 @@ export class AdminPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(private dataService: DataService) {
const { systemMessage } = this.dataService.fetchInfo();
this.hasMessage = !!systemMessage;
}
/**
* Initializes the controller
*/
public ngOnInit() {}
public ngOnDestroy() {

View File

@ -5,7 +5,8 @@
*ngFor="let link of [
{ iconName: 'reader-outline', path: 'overview' },
{ iconName: 'people-outline', path: 'users' },
{ iconName: 'server-outline', path: 'market-data' }
{ iconName: 'server-outline', path: 'market-data' },
{ iconName: 'flash-outline', path: 'jobs' }
]"
#rla="routerLinkActive"
mat-tab-link

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatTabsModule } from '@angular/material/tabs';
import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module';
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
@ -19,6 +20,7 @@ import { AdminPageComponent } from './admin-page.component';
imports: [
AdminPageRoutingModule,
CommonModule,
GfAdminJobsModule,
GfAdminMarketDataModule,
GfAdminOverviewModule,
GfAdminUsersModule,

View File

@ -11,6 +11,7 @@
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
gf-admin-jobs,
gf-admin-market-data,
gf-admin-overview,
gf-admin-users {

View File

@ -16,9 +16,6 @@ import { takeUntil } from 'rxjs/operators';
export class AuthPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private route: ActivatedRoute,
private router: Router,
@ -26,9 +23,6 @@ export class AuthPageComponent implements OnDestroy, OnInit {
private tokenStorageService: TokenStorageService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.route.params
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -10,9 +10,6 @@ import { Subject } from 'rxjs';
export class BlogPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
public ngOnDestroy() {

View File

@ -14,7 +14,7 @@
<div class="h6 m-0 text-truncate">
First months in Open Source
</div>
<div class="d-flex text-muted">05.01.2021</div>
<div class="d-flex text-muted">05.01.2022</div>
</div>
<div class="align-items-center d-flex">
<ion-icon

View File

@ -18,9 +18,6 @@ export class FeaturesPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -29,9 +26,6 @@ export class FeaturesPageComponent implements OnDestroy {
this.info = this.dataService.fetchInfo();
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -4,14 +4,12 @@
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Features
</h3>
<mat-card class="mb-4">
<mat-card-content>
<p>
Check out the numerous features of <strong>Ghostfolio</strong> to
manage your wealth.
</p>
</mat-card-content>
</mat-card>
<div class="mb-4">
<p>
Check out the numerous features of <strong>Ghostfolio</strong> to
manage your wealth.
</p>
</div>
<div class="row">
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">
@ -110,11 +108,10 @@
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span i18n>Portfolio Calculations</span>
<ion-icon
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
class="ml-1"
></gf-premium-indicator>
</h4>
<p class="m-0">
Check the rate of return of your portfolio for
@ -129,11 +126,10 @@
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span i18n>Portfolio Allocations</span>
<ion-icon
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
class="ml-1"
></gf-premium-indicator>
</h4>
<p class="m-0">
Check the allocations of your portfolio by account, asset class,
@ -171,10 +167,7 @@
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span i18n>Market Mood</span>
<ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
</h4>
<p class="m-0">
Check the current market mood (<a [routerLink]="['/resources']"
@ -189,11 +182,10 @@
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span i18n>Static Analysis</span>
<ion-icon
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
class="ml-1"
></gf-premium-indicator>
</h4>
<p class="m-0">
Identify potential risks in your portfolio with Ghostfolio

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { FeaturesPageRoutingModule } from './features-page-routing.module';
import { FeaturesPageComponent } from './features-page.component';
@ -9,8 +10,9 @@ import { FeaturesPageComponent } from './features-page.component';
@NgModule({
declarations: [FeaturesPageComponent],
imports: [
FeaturesPageRoutingModule,
CommonModule,
FeaturesPageRoutingModule,
GfPremiumIndicatorModule,
MatButtonModule,
MatCardModule
],

View File

@ -29,9 +29,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -70,9 +67,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {}
public ngOnDestroy() {

View File

@ -39,18 +39,12 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private dataService: DataService,
private router: Router,
private tokenStorageService: TokenStorageService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
const { demoAuthToken } = this.dataService.fetchInfo();

View File

@ -1,19 +1,28 @@
<div class="intro-container mb-5">
<div class="intro-inner-container mx-auto">
<div class="h-100 intro w-100"></div>
<div class="container">
<div class="row">
<div class="col text-center">
<h1 class="font-weight-bold intro my-5" i18n>
Manage your wealth like a boss
</h1>
<div>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
target="_blank"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
class="rounded video"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
</div>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div
class="align-items-center d-flex flex-column justify-content-center w-100"
>
<gf-logo size="large"></gf-logo>
<p class="lead m-0">Wealth Management Software</p>
</div>
</div>
<div class="button-container row">
<div class="align-items-center col d-flex justify-content-center">
<div class="py-5 text-center">
@ -43,25 +52,12 @@
<div class="row my-5">
<div class="col text-center">
<h2 class="h4 mb-1 text-center">
Protect your <strong>wealth</strong>. Refine your
Protect your <strong>assets</strong>. Refine your
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<strong>Ghostfolio</strong> empowers busy people to keep track of
stocks, ETFs or cryptocurrencies and make solid, data-driven investment
decisions.
</p>
<p>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
Ghostfolio empowers busy people to keep track of stocks, ETFs or
cryptocurrencies and make solid, data-driven investment decisions.
</p>
</div>
</div>
@ -198,3 +194,19 @@
</a>
</div>
</div>
<div class="container">
<div class="d-block row">
<div class="outro-inner-container mx-auto">
<div class="h-100 w-100"></div>
</div>
</div>
<div class="row">
<div
class="align-items-center d-flex flex-column justify-content-center w-100"
>
<gf-logo size="medium"></gf-logo>
<div>Wealth Management Software</div>
</div>
</div>
</div>

View File

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
@ -13,19 +15,32 @@
}
}
.intro-container {
margin-top: -5rem;
.intro {
font-size: 4vw;
line-height: 1;
.intro-inner-container {
aspect-ratio: 16 / 9;
max-height: 66vh;
@media (max-width: 575.98px) {
font-size: 10vw;
}
}
.intro {
background-image: url('/assets/intro.jpg');
background-position: top left;
background-repeat: no-repeat;
background-size: contain;
}
.outro-inner-container {
aspect-ratio: 16 / 9;
max-height: 66vh;
div {
background-image: url('/assets/intro.jpg');
background-position: top left;
background-repeat: no-repeat;
background-size: contain;
}
}
.video {
border: 1px solid rgba(var(--dark-dividers));
&:hover {
border-color: rgba(var(--palette-primary-500), 1);
}
}
}
@ -37,9 +52,13 @@
}
}
.intro-container {
.intro {
.outro-inner-container {
div {
background-image: url('/assets/intro-dark.jpg') !important;
}
}
.video {
border-color: rgba(var(--light-dividers));
}
}

View File

@ -68,7 +68,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
| 'value'
>;
};
public positionsArray: PortfolioPosition[];
public routeQueryParams: Subscription;
public sectors: {
[name: string]: { name: string; value: number };
@ -87,9 +86,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -116,9 +112,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
@ -229,7 +222,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
};
this.positions = {};
this.positionsArray = [];
this.sectors = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
@ -285,7 +277,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
exchange: position.exchange,
name: position.name
};
this.positionsArray.push(position);
if (position.assetClass !== AssetClass.CASH) {
// Prepare analysis data by continents, countries and sectors except for cash

View File

@ -37,12 +37,11 @@
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Currency</span
><ion-icon
><span i18n>By Currency</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -67,11 +66,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Asset Class</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -96,11 +94,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Position</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -129,11 +126,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Sector</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -159,11 +155,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Continent</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -188,11 +183,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -220,11 +214,10 @@
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>Regions</span
><ion-icon
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
class="ml-1"
></gf-premium-indicator
></mat-card-title>
<gf-toggle
[defaultValue]="period"
@ -269,14 +262,4 @@
</mat-card>
</div>
</div>
<div class="row">
<div class="col-lg">
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
</div>
</div>
</div>

View File

@ -1,11 +1,11 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AllocationsPageRoutingModule } from './allocations-page-routing.module';
@ -13,19 +13,17 @@ import { AllocationsPageComponent } from './allocations-page.component';
@NgModule({
declarations: [AllocationsPageComponent],
exports: [],
imports: [
AllocationsPageRoutingModule,
CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule,
GfPositionsTableModule,
GfPremiumIndicatorModule,
GfToggleModule,
GfWorldMapChartModule,
GfValueModule,
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AllocationsPageModule {}

View File

@ -27,14 +27,5 @@
font-size: 90%;
}
}
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More