Compare commits

...

62 Commits

Author SHA1 Message Date
1851ae137f Release 2.2.0 (#2346) 2023-09-17 07:17:20 +02:00
6f6ff94979 Improve sidebar (#2343)
* Improve sidebar

* Improve style of system message

* Update changelog
2023-09-17 07:08:26 +02:00
7f25066f0f Remove ALPHA_VANTAGE_API_KEY (#2345) 2023-09-17 06:54:38 +02:00
fc795aaa8c Update postgres to version 15 in docker-compose files (#1596)
* Update postgres to version 15 in docker-compose files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 06:52:41 +02:00
d0112968e8 Feature/introduce sidebar navigation on desktop (#2340)
* Introduce sidebar navigation on desktop

* Update changelog
2023-09-16 14:40:05 +02:00
522025ffa0 Fix entry (#2339) 2023-09-16 14:37:08 +02:00
27bf662281 Release 2.1.0 (#2338) 2023-09-15 19:48:09 +02:00
93c27277c6 Extend sitemap with Italian pages (#2337) 2023-09-15 19:46:32 +02:00
5e6adfcef5 Feature/improve language localization for german 20230915 (#2336)
* Improve language localization

* Update changelog
2023-09-15 19:38:15 +02:00
ab691bb27a Feature/remove account type from user interface (#2335)
* Remove account type from user interface and set it optional

* Update changelog
2023-09-15 19:11:20 +02:00
8fc5676443 Feature/improve timeout of data source requests (#2330)
* Improve timeout

* Update changelog
2023-09-15 16:25:01 +02:00
1fe1e2fe0c Feature/improve read only mode (#2322)
* Improve read-only mode

* Update changelog
2023-09-15 16:22:39 +02:00
921d38a706 Feature/harmonize style of granted access user interface (#2326)
* Harmonize style

* Update changelog
2023-09-15 16:21:14 +02:00
6161d5e77c Feature/improve logger output of info service (#2331)
* Improve context of logger output

* Update changelog
2023-09-15 08:26:08 +02:00
369386f976 Add drop file functionality on import (#2323)
* Add drop file functionality on import

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-14 21:12:48 +02:00
41437636b1 Update messages.it.xlf (#2325)
* Update messages.it.xlf

* Update changelog
2023-09-14 19:48:47 +02:00
b21884eb66 Feature/harmonize logger output (#2321)
* Harmonize logger output

* Update changelog
2023-09-13 08:39:37 +02:00
1c5437e1fd Extend sitemap.xml with dutch pages (#2318) 2023-09-13 08:39:09 +02:00
58278ba5e6 Bugfix/fix dutch localization of portfolio summary (#2315)
* Revert reserved keyword (plural)

* Update changelog
2023-09-11 12:04:09 +02:00
921f3e9807 Update dutch translation (#2314)
* Update dutch translation

* Update changelog
2023-09-11 11:42:36 +02:00
75ca125a70 Add home server systems (#2311) 2023-09-10 08:01:01 +02:00
a1fd4e7a38 Improve wording (#2312) 2023-09-10 08:00:07 +02:00
0d5a8eb33e Add Ghostfolio 2.0 (#2309) 2023-09-09 09:10:36 +02:00
b088df2fa3 Release 2.0.0 (#2310) 2023-09-09 08:29:18 +02:00
f45d8f616a Bugfix/fix blog post ghostfolio 2 (#2307)
* Fix month

* Update sitemap.xml

* Update locales
2023-09-08 21:36:01 +02:00
d8300502ce Feature/upgrade yahoo finance2 to version 2.5.0 (#2306)
* Upgrade yahoo-finance2 to version 2.5.0

* Update changelog
2023-09-08 21:22:57 +02:00
502d51ad29 Feature/add blog post ghostfolio 2 (#2269)
* Add blog post: Ghostfolio 2.0

* Update changelog
2023-09-08 20:45:31 +02:00
bc33e5f147 Feature/remove deprecated environment variable base currency (#2255)
* Remove the deprecated environment variable BASE_CURRENCY

* Update changelog
2023-09-08 20:43:23 +02:00
48ba8f936b Feature/deactivate internet identity for account registration (#2293)
* Deactivate Internet Identity

* Update changelog
2023-09-08 20:23:22 +02:00
05ec4cce05 Add Portuguese landing page (#2301) 2023-09-08 17:06:58 +02:00
d74f283707 Eliminate prisma service (#2286)
* Eliminate prisma service
2023-09-08 17:05:42 +02:00
0f8bc7db32 Bugfix/do not remove countries and sectors in yahoo finance data enhancer (#2297)
* Do not remove countries and sectors

* Update changelog
2023-09-08 15:07:17 +02:00
431500f28a Update docker compose files to version 3.9 (#2299)
* Update docker compose files to version 3.9

* Format yml files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-07 20:44:52 +02:00
9672de174e Feature/improve language localization for german 20230903 (#2294)
* Update locales

* Update changelog
2023-09-07 19:20:30 +02:00
c6aa06b933 Feature/improve import validation (#2305)
* Improve import validation

* Update changelog

Co-authored-by: httpiga <36515569+httpiga@users.noreply.github.com>
2023-09-07 18:28:47 +02:00
1f46a6b6f3 Clean up (#2291) 2023-09-05 19:44:45 +02:00
1bed940bc0 Feature/refresh cryptocurrencies list 20230903 (#2290)
* Refresh cryptocurrencies list

* Add CyberConnect

* Update changelog
2023-09-04 09:07:06 +02:00
f9eb3cc3c5 Release 1.305.0 (#2289) 2023-09-03 08:16:02 +02:00
2519c3ffb0 Bugfix/fix alignment in menu of impersonation mode (#2284)
* Fix alignment

* Update changelog
2023-09-02 17:57:50 +02:00
91013d1d10 Bugfix/fix alignment in header navigation (#2285)
* Fix alignment

* Update changelog
2023-09-02 17:13:13 +02:00
6deefb9c43 Update OSS Friends (#2282) 2023-09-02 11:52:29 +02:00
d0744e07df Feature/upgrade replace in file to version 7.0.1 (#2277)
* Upgrade replace-in-file to version 7.0.1

* Update changelog
2023-09-02 08:58:25 +02:00
93e1ee3ba7 Feature/improve localization of personal finance tools (#2274)
* Improve localization

* Update changelog
2023-09-02 08:58:10 +02:00
dceaa55a6c Feature/add hacker news logo to landing page (#2281)
* Add Hacker News

* Update changelog
2023-09-02 08:39:32 +02:00
8b4d55925d Feature/upgrade yahoo finance2 to version 2.4.4 (#2276)
* Upgrade yahoo-finance2 to version 2.4.4

* Update changelog
2023-09-02 08:36:46 +02:00
754b49e50f Feature/shorten page titles (#2273)
* Shorten page titles

* Update changelog
2023-08-31 18:22:22 +02:00
6ccbda8169 Feature/upgrade prisma to version 5.2.0 (#2217)
* Upgrade prisma to version 5.2.0

* Update changelog
2023-08-29 13:44:53 +02:00
b0fb986208 Release 1.304.0 (#2272) 2023-08-27 11:19:14 +02:00
0b59fc639d Feature/upgrade prettier to version 3 (#2163)
* Upgrade prettier to version 3.0.2

* Prettify code

* Update changelog
2023-08-27 11:13:11 +02:00
7ddd6f27b5 Feature/upgrade nx to version 16.7.4 (#2271)
* Upgrade Nx to version 16.7.4

* Update changelog
2023-08-27 10:44:06 +02:00
c5d56f4b47 Fix border (#2268) 2023-08-27 10:20:36 +02:00
2f2b712999 Fix breadcrumb (#2267) 2023-08-27 10:20:21 +02:00
c2fd31f5e5 Feature/add health check endpoints for data enhancers (#2265)
* Add health check for data enhancers

* Update changelog
2023-08-27 10:19:53 +02:00
f2d70f9070 Sort imports (#2266) 2023-08-26 11:22:19 +02:00
f41dd9cd8e Fix lint script (#2264) 2023-08-25 15:13:04 +02:00
7d238b4935 Release 1.303.0 (#2261) 2023-08-23 18:52:59 +02:00
da6591fca0 Bugfix/fix base url in trackinsight data enhancer (#2258)
* Fix base url

* Update changelog
2023-08-23 18:51:02 +02:00
1f9b9e9998 Feature/blog post ghostfolio joins oss friends (#2260)
* Add blog post: Ghostfolio joins OSS Friends

* Update changelog
2023-08-23 18:49:53 +02:00
49c4ea306d Feature/improve oss friends page (#2257)
* Improve OSS Friends page

* Update changelog
2023-08-22 09:02:39 +02:00
ccb5c664ef Feature/refresh cryptocurrencies list 20230821 (#2256)
* Update cryptocurrencies.json

* Update changelog
2023-08-21 20:20:59 +02:00
97e165ff69 Improve localization (#2254) 2023-08-21 18:05:08 +02:00
45aefb6a45 Reorder charts (#2253) 2023-08-21 18:04:49 +02:00
151 changed files with 17012 additions and 8124 deletions

View File

@ -11,6 +11,5 @@ POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -9,6 +9,7 @@
],
"attributeSort": "ASC",
"endOfLine": "auto",
"plugins": ["prettier-plugin-organize-attributes"],
"printWidth": 80,
"singleQuote": true,
"tabWidth": 2,

View File

@ -5,6 +5,106 @@ 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).
## 2.2.0 - 2023-09-17
### Added
- Introduced a sidebar navigation on desktop
### Changed
- Improved the style of the system message
- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files
## 2.1.0 - 2023-09-15
### Added
- Added support to drop a file in the import activities dialog
- Added a timeout to all data source requests
### Changed
- Harmonized the style of the user interface for granting and revoking public access to share the portfolio
- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema
- Improved the logger output of the info service
- Harmonized the logger output: `<symbol> (<dataSource>)`
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Dutch (`nl`)
- Improved the read-only mode
### Fixed
- Fixed the timeout in _EOD Historical Data_ requests
- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`)
## 2.0.0 - 2023-09-09
### Added
- Added support for the cryptocurrency _CyberConnect_
- Added a blog post: _Announcing Ghostfolio 2.0_
### Changed
- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY`
- Improved the validation in the activities import
- Deactivated _Internet Identity_ as a social login provider for the account registration
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
- Changed the version in the `docker-compose` files from `3.7` to `3.9`
- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0`
### Fixed
- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed
## 1.305.0 - 2023-09-03
### Added
- Added _Hacker News_ to the _As seen in_ section on the landing page
### Changed
- Shortened the page titles
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.16.2` to `5.2.0`
- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1`
- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4`
### Fixed
- Fixed the alignment in the header navigation
- Fixed the alignment in the menu of the impersonation mode
## 1.304.0 - 2023-08-27
### Added
- Added health check endpoints for data enhancers
### Changed
- Upgraded `Nx` from version `16.7.2` to `16.7.4`
- Upgraded `prettier` from version `2.8.4` to `3.0.2`
## 1.303.0 - 2023-08-23
### Added
- Added a blog post: _Ghostfolio joins OSS Friends_
### Changed
- Refreshed the cryptocurrencies list
- Improved the _OSS Friends_ page
### Fixed
- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data
## 1.302.0 - 2023-08-20
### Changed
@ -1443,7 +1543,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Set up the language localization for Italiano (`it`)
- Set up the language localization for Italian (`it`)
- Extended the landing page
## 1.195.0 - 20.09.2022
@ -2866,7 +2966,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Supported the management of additional currencies in the admin control panel
- Introduced the system message
- Introduced the read only mode
- Introduced the read-only mode
### Changed

View File

@ -13,6 +13,8 @@
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -136,9 +138,9 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community)
### Home Server Systems (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash';
export class CreateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash';
export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;

View File

@ -6,7 +6,12 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
@ -23,8 +28,6 @@ import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
@ -34,9 +37,7 @@ export class AdminService {
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
) {}
public async addAssetProfile({
dataSource,
@ -80,15 +81,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== this.baseCurrency;
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
return {
label1: this.baseCurrency,
label1: DEFAULT_CURRENCY,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
this.baseCurrency,
DEFAULT_CURRENCY,
currency
)
};
@ -306,7 +307,9 @@ export class AdminService {
response = await this.propertyService.delete({ key });
}
if (key === PROPERTY_CURRENCIES) {
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
} else if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
}

View File

@ -41,9 +41,8 @@ export class AuthController {
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
);
const authToken =
await this.authService.validateAnonymousLogin(accessToken);
return { authToken };
} catch {
throw new HttpException(

View File

@ -55,7 +55,7 @@ export class AuthService {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden');
}

View File

@ -7,10 +7,10 @@ import {
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service';
import { parseISO } from 'date-fns';
@Controller('exchange-rate')
export class ExchangeRateController {

View File

@ -26,18 +26,8 @@ export class ExportService {
where: { userId }
})
).map(
({
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
}) => {
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
return {
accountType,
balance,
comment,
currency,

View File

@ -18,6 +18,19 @@ export class HealthController {
@Get()
public async getHealth() {}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
@Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider(
@ -30,9 +43,8 @@ export class HealthController {
);
}
const hasResponse = await this.healthService.hasResponseFromDataProvider(
dataSource
);
const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) {
throw new HttpException(

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule],
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
providers: [HealthService]
})
export class HealthModule {}

View File

@ -1,3 +1,4 @@
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
@Injectable()
export class HealthService {
public constructor(
private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService
) {}
public async hasResponseFromDataEnhancer(aName: string) {
return this.dataEnhancerService.enhance(aName);
}
public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource);
}

View File

@ -566,7 +566,7 @@ export class ImportService {
])
)?.[symbol];
if (!assetProfile) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);

View File

@ -1,6 +1,7 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -28,11 +29,11 @@ import { InfoService } from './info.service';
signOptions: { expiresIn: '30 days' }
}),
PlatformModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
TagModule
TagModule,
UserModule
],
providers: [InfoService]
})

View File

@ -1,12 +1,14 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
@ -44,10 +46,10 @@ export class InfoService {
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly platformService: PlatformService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
private readonly tagService: TagService,
private readonly userService: UserService
) {}
public async get(): Promise<InfoItem> {
@ -139,18 +141,13 @@ export class InfoService {
subscriptions,
systemMessage,
tags,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies()
};
}
private async countActiveUsers(aDays: number) {
return await this.prismaService.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
return this.userService.count({
where: {
AND: [
{
@ -172,16 +169,24 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
{
headers: { 'User-Agent': 'request' }
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
}
).json<any>();
return pull_count;
} catch (error) {
Logger.error(error, 'InfoService');
Logger.error(error, 'InfoService - DockerHub');
return undefined;
}
@ -189,7 +194,16 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> {
try {
const { body } = await got('https://github.com/ghostfolio/ghostfolio');
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
@ -199,7 +213,7 @@ export class InfoService {
).text()
);
} catch (error) {
Logger.error(error, 'InfoService');
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
@ -207,26 +221,31 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
{
headers: { 'User-Agent': 'request' }
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
}
).json<any>();
return stargazers_count;
} catch (error) {
Logger.error(error, 'InfoService');
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countNewUsers(aDays: number) {
return await this.prismaService.user.count({
orderBy: {
createdAt: 'desc'
},
return this.userService.count({
where: {
AND: [
{
@ -317,11 +336,10 @@ export class InfoService {
return undefined;
}
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
return JSON.parse(stripeConfig.value);
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> {
@ -331,24 +349,31 @@ export class InfoService {
PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string;
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { data } = await got(
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
Authorization: `Bearer ${this.configurationService.get(
'BETTER_UPTIME_API_KEY'
)}`
}
},
// @ts-ignore
signal: abortController.signal
}
).json<any>();
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'InfoService');
Logger.error(error, 'InfoService - Better Stack');
return undefined;
}

View File

@ -1,4 +1,5 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -41,10 +42,18 @@ export class LogoService {
}
private getBuffer(aUrl: string) {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{
headers: { 'User-Agent': 'request' }
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
}
).buffer();
}

View File

@ -105,7 +105,6 @@ describe('CurrentRateService', () => {
null,
null,
null,
null,
null
);
marketDataService = new MarketDataService(null);

View File

@ -784,7 +784,7 @@ export class PortfolioCalculator {
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for symbol ${currentPosition.symbol}`,
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;

View File

@ -10,7 +10,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
private baseCurrency: string;
public constructor(
private readonly accessService: AccessService,
private readonly apiService: ApiService,
@ -57,9 +58,7 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
) {}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@ -442,8 +441,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
);
})
.reduce((a, b) => a + b, 0);

View File

@ -11,12 +11,12 @@ import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/ac
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS,
UNKNOWN_KEY
@ -90,11 +90,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable()
export class PortfolioService {
private baseCurrency: string;
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -104,9 +101,7 @@ export class PortfolioService {
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
) {}
public async getAccounts({
filters,
@ -470,9 +465,8 @@ export class PortfolioService {
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(startDate);
const cashDetails = await this.accountService.getCashDetails({
filters,
@ -810,9 +804,8 @@ export class PortfolioService {
const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions(
portfolioStart
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(portfolioStart);
const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol
@ -1046,9 +1039,8 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(startDate);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
@ -1238,9 +1230,8 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions(
portfolioStart
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(portfolioStart);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
@ -1772,7 +1763,7 @@ export class PortfolioService {
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency;
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({
filters,
@ -1994,7 +1985,7 @@ export class PortfolioService {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
DEFAULT_CURRENCY
);
}

View File

@ -93,9 +93,8 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
const session =
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
await this.createSubscription({
price: session.amount_total / 100,

View File

@ -15,13 +15,13 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service';
import { parseISO } from 'date-fns';
@Controller('symbol')
export class SymbolController {

View File

@ -19,22 +19,22 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
import { sortBy, without } from 'lodash';
const crypto = require('crypto');
@Injectable()
export class UserService {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
) {}
public async count(args?: Prisma.UserCountArgs) {
return this.prismaService.user.count(args);
}
public async getUser(
@ -188,6 +188,11 @@ export class UserService {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
currentPermissions = without(
currentPermissions,
permissions.createAccess
);
// Reset benchmark
user.Settings.settings.benchmark = undefined;
}
@ -267,7 +272,7 @@ export class UserService {
...data,
Account: {
create: {
currency: this.baseCurrency,
currency: DEFAULT_CURRENCY,
isDefault: true,
name: 'Default Account'
}
@ -275,7 +280,7 @@ export class UserService {
Settings: {
create: {
settings: {
currency: this.baseCurrency
currency: DEFAULT_CURRENCY
}
}
}

View File

@ -51,7 +51,9 @@
"3FT": "ThreeFold Token",
"3ULL": "3ULL Coin",
"3XD": "3DChain",
"420CHAN": "420chan",
"4ART": "4ART Coin",
"4CHAN": "4Chan",
"4JNET": "4JNET",
"77G": "GraphenTech",
"7E": "7ELEVEN",
@ -60,6 +62,7 @@
"8BT": "8 Circuit Studios",
"8PAY": "8Pay",
"8X8": "8X8 Protocol",
"9GAG": "9GAG",
"A5T": "Alpha5",
"AAA": "Moon Rabbit",
"AAB": "AAX Token",
@ -101,6 +104,7 @@
"ACN": "AvonCoin",
"ACOIN": "ACoin",
"ACP": "Anarchists Prime",
"ACQ": "Acquire.Fi",
"ACS": "Access Protocol",
"ACT": "Achain",
"ACTIN": "Actinium",
@ -180,7 +184,7 @@
"AGX": "Agricoin",
"AHOO": "Ahoolee",
"AHT": "AhaToken",
"AI": "Multiverse",
"AI": "AiDoge",
"AIB": "AdvancedInternetBlock",
"AIBB": "AiBB",
"AIBK": "AIB Utility Token",
@ -213,6 +217,7 @@
"AKA": "Akroma",
"AKITA": "Akita Inu",
"AKN": "Akoin",
"AKNC": "Aave KNC v1",
"AKRO": "Akropolis",
"AKT": "Akash Network",
"AKTIO": "AKTIO Coin",
@ -237,12 +242,14 @@
"ALIC": "AliCoin",
"ALICE": "My Neighbor Alice",
"ALIEN": "AlienCoin",
"ALINK": "Aave LINK v1",
"ALIS": "ALISmedia",
"ALITA": "Alita Network",
"ALIX": "AlinX",
"ALKI": "Alkimi",
"ALLBI": "ALL BEST ICO",
"ALLEY": "NFT Alley",
"ALLIN": "All in",
"ALN": "Aluna",
"ALOHA": "Aloha",
"ALP": "Alphacon",
@ -410,12 +417,14 @@
"ARIX": "Arix",
"ARK": "ARK",
"ARKER": "Arker",
"ARKM": "Arkham",
"ARKN": "Ark Rivals",
"ARM": "Armory Coin",
"ARMOR": "ARMOR",
"ARMR": "ARMR",
"ARMS": "2Acoin",
"ARNA": "ARNA Panacea",
"ARNM": "Arenum",
"ARNO": "ARNO",
"ARNX": "Aeron",
"ARNXM": "Armor NXM",
@ -472,6 +481,7 @@
"ASTO": "Altered State Token",
"ASTON": "Aston",
"ASTR": "Astar",
"ASTRAFER": "Astrafer",
"ASTRAL": "Astral",
"ASTRO": "AstroSwap",
"ASTROC": "Astroport Classic",
@ -531,6 +541,7 @@
"AURY": "Aurory",
"AUSCM": "Auric Network",
"AUSD": "Appeal dollar",
"AUSDC": "Aave USDC v1",
"AUT": "Autoria",
"AUTHORSHIP": "Authorship",
"AUTO": "Auto",
@ -612,6 +623,7 @@
"BACK": "DollarBack",
"BACOIN": "BACoin",
"BACON": "BaconDAO (BACON)",
"BAD": "Bad Idea AI",
"BADGER": "Badger DAO",
"BAG": "BondAppetit",
"BAGS": "Basis Gold Share",
@ -662,6 +674,7 @@
"BBCT": "TraDove B2BCoin",
"BBDT": "BBD Token",
"BBF": "Bubblefong",
"BBFT": "Block Busters Tech Token",
"BBG": "BigBang",
"BBGC": "BigBang Game",
"BBI": "BelugaPay",
@ -725,6 +738,7 @@
"BDX": "Beldex",
"BDY": "Buddy DAO",
"BEACH": "BeachCoin",
"BEAI": "BeNFT Solutions",
"BEAM": "Beam",
"BEAN": "BeanCash",
"BEAST": "CryptoBeast",
@ -806,6 +820,7 @@
"BIDR": "Binance IDR Stable Coin",
"BIFI": "Beefy.Finance",
"BIFIF": "BiFi",
"BIG": "Big Eyes",
"BIGHAN": "BighanCoin",
"BIGSB": "BigShortBets",
"BIGUP": "BigUp",
@ -1090,6 +1105,7 @@
"BRNK": "Brank",
"BRNX": "Bronix",
"BRO": "Bitradio",
"BROCK": "Bitrock",
"BRONZ": "BitBronze",
"BRT": "Bikerush",
"BRTR": "Barter",
@ -1226,7 +1242,7 @@
"BULL": "Bullieverse",
"BULLC": "BuySell",
"BULLION": "BullionFX",
"BULLS": "BullshitCoin",
"BULLS": "Bull Coin",
"BULLSH": "Bullshit Inu",
"BUMN": "BUMooN",
"BUMP": "Bumper",
@ -1277,6 +1293,7 @@
"BZKY": "Bizkey",
"BZL": "BZLCoin",
"BZNT": "Bezant",
"BZR": "Bazaars",
"BZRX": "bZx Protocol",
"BZX": "Bitcoin Zero",
"BZZ": "Swarmv",
@ -1319,8 +1336,10 @@
"CAP": "BottleCaps",
"CAPD": "Capdax",
"CAPP": "Cappasity",
"CAPRICOIN": "CapriCoin",
"CAPS": "Ternoa",
"CAPT": "Bitcoin Captain",
"CAPTAINPLANET": "Captain Planet",
"CAR": "CarBlock",
"CARAT": "Carats Token",
"CARBON": "Carboncoin",
@ -1478,6 +1497,7 @@
"CHECKR": "CheckerChain",
"CHECOIN": "CheCoin",
"CHEDDA": "Chedda",
"CHEEL": "Cheelee",
"CHEESE": "CHEESE",
"CHEESUS": "Cheesus",
"CHEQ": "CHEQD Network",
@ -1520,7 +1540,8 @@
"CHX": "Own",
"CHY": "Concern Poverty Chain",
"CHZ": "Chiliz",
"CIC": "CIChain",
"CIC": "Crazy Internet Coin",
"CICHAIN": "CIChain",
"CIF": "Crypto Improvement Fund",
"CIM": "COINCOME",
"CIN": "CinderCoin",
@ -1630,7 +1651,6 @@
"COB": "Cobinhood",
"COC": "Coin of the champions",
"COCK": "Shibacock",
"COCOS": "COCOS BCX",
"CODEO": "Codeo Token",
"CODEX": "CODEX Finance",
"CODI": "Codi Finance",
@ -1659,7 +1679,7 @@
"COLX": "ColossusCoinXT",
"COM": "Coliseum",
"COMB": "Combo",
"COMBO": "Furucombo",
"COMBO": "COMBO",
"COMFI": "CompliFi",
"COMM": "Community Coin",
"COMMUNITYCOIN": "Community Coin",
@ -1672,7 +1692,6 @@
"CONI": "CoinBene",
"CONS": "ConSpiracy Coin",
"CONSENTIUM": "Consentium",
"CONT": "Contentos",
"CONUN": "CONUN",
"CONV": "Convergence",
"COOK": "Cook",
@ -1683,17 +1702,19 @@
"COPS": "Cops Finance",
"COR": "Corion",
"CORAL": "CoralPay",
"CORE": "Coreum",
"CORE": "Core",
"COREDAO": "coreDAO",
"COREG": "Core Group Asset",
"COREUM": "Coreum",
"CORGI": "Corgi Inu",
"CORN": "CORN",
"CORX": "CorionX",
"COS": "COS",
"COS": "Contentos",
"COSHI": "CoShi Inu",
"COSM": "CosmoChain",
"COSMIC": "CosmicSwap",
"COSP": "Cosplay Token",
"COSS": "COS",
"COSX": "Cosmecoin",
"COT": "CoTrader",
"COTI": "COTI",
@ -1729,7 +1750,7 @@
"CPOOL": "Clearpool",
"CPROP": "CPROP",
"CPRX": "Crypto Perx",
"CPS": "CapriCoin",
"CPS": "Cryptostone",
"CPT": "Cryptaur",
"CPU": "CPUcoin",
"CPX": "Apex Token",
@ -1796,6 +1817,7 @@
"CRTS": "Cratos",
"CRU": "Crust Network",
"CRV": "Curve DAO Token",
"CRVUSD": "crvUSD",
"CRW": "Crown Coin",
"CRWD": "CRWD Network",
"CRWNY": "Crowny Token",
@ -1843,7 +1865,7 @@
"CTLX": "Cash Telex",
"CTN": "Continuum Finance",
"CTO": "Crypto",
"CTP": "Captain Planet",
"CTP": "Ctomorrow Platform",
"CTPL": "Cultiplan",
"CTPT": "Contents Protocol",
"CTR": "Creator Platform",
@ -2007,6 +2029,7 @@
"DBC": "DeepBrain Chain",
"DBCCOIN": "Datablockchain",
"DBD": "Day By Day",
"DBEAR": "DBear Coin",
"DBET": "Decent.bet",
"DBIC": "DubaiCoin",
"DBIX": "DubaiCoin",
@ -2058,6 +2081,7 @@
"DEEP": "DeepCloud AI",
"DEEPG": "Deep Gold",
"DEEX": "DEEX",
"DEEZ": "DEEZ NUTS",
"DEFI": "Defi",
"DEFI5": "DEFI Top 5 Tokens Index",
"DEFIL": "DeFIL",
@ -2162,11 +2186,12 @@
"DIEM": "Facebook Diem",
"DIESEL": "Diesel",
"DIFX": "Digital Financial Exchange",
"DIG": "Dignity",
"DIG": "DIEGO",
"DIGG": "DIGG",
"DIGIC": "DigiCube",
"DIGIF": "DigiFel",
"DIGITAL": "Digital Reserve Currency",
"DIGNITY": "Dignity",
"DIGS": "Diggits",
"DIKO": "Arkadiko",
"DILI": "D Community",
@ -2246,6 +2271,7 @@
"DOGBOSS": "Dog Boss",
"DOGDEFI": "DogDeFiCoin",
"DOGE": "Dogecoin",
"DOGE20": "Doge 2.0",
"DOGEBNB": "DogeBNB",
"DOGEC": "DogeCash",
"DOGECEO": "Doge CEO",
@ -2539,7 +2565,7 @@
"ELONGT": "Elon GOAT",
"ELONONE": "AstroElon",
"ELP": "Ellerium",
"ELS": "Elysium",
"ELS": "Ethlas",
"ELT": "Element Black",
"ELTC2": "eLTC",
"ELTCOIN": "ELTCOIN",
@ -2548,6 +2574,7 @@
"ELVN": "11Minutes",
"ELX": "Energy Ledger",
"ELY": "Elysian",
"ELYSIUM": "Elysium",
"EM": "Eminer",
"EMANATE": "EMANATE",
"EMAR": "EmaratCoin",
@ -2559,6 +2586,7 @@
"EMC2": "Einsteinium",
"EMD": "Emerald",
"EMIGR": "EmiratesGoldCoin",
"EML": "EML Protocol",
"EMN.CUR": "Eastman Chemical",
"EMON": "Ethermon",
"EMOT": "Sentigraph.io",
@ -2692,6 +2720,7 @@
"ETHD": "Ethereum Dark",
"ETHER": "Etherparty",
"ETHERDELTA": "EtherDelta",
"ETHERKING": "Ether Kingdoms Token",
"ETHERNITY": "Ethernity Chain",
"ETHF": "EthereumFair",
"ETHIX": "EthicHub",
@ -2709,6 +2738,7 @@
"ETHSHIB": "Eth Shiba",
"ETHV": "Ethverse",
"ETHW": "Ethereum PoW",
"ETHX": "Stader ETHx",
"ETHY": "Ethereum Yield",
"ETI": "EtherInc",
"ETK": "Energi Token",
@ -2722,7 +2752,7 @@
"ETR": "Electric Token",
"ETRNT": "Eternal Trusts",
"ETS": "ETH Share",
"ETSC": "Ether star blockchain",
"ETSC": "Ether star blockchain",
"ETT": "EncryptoTel",
"ETY": "Ethereum Cloud",
"ETZ": "EtherZero",
@ -2773,6 +2803,7 @@
"EXB": "ExaByte (EXB)",
"EXC": "Eximchain",
"EXCC": "ExchangeCoin",
"EXCHANGEN": "ExchangeN",
"EXCL": "Exclusive Coin",
"EXE": "ExeCoin",
"EXFI": "Flare Finance",
@ -2781,7 +2812,7 @@
"EXLT": "ExtraLovers",
"EXM": "EXMO Coin",
"EXMR": "EXMR FDN",
"EXN": "ExchangeN",
"EXN": "Exeno",
"EXO": "Exosis",
"EXP": "Expanse",
"EXRD": "Radix",
@ -2814,6 +2845,7 @@
"FAIR": "FairCoin",
"FAIRC": "Faireum Token",
"FAIRG": "FairGame",
"FAKE": "FAKE COIN",
"FAKT": "Medifakt",
"FALCONS": "Falcon Swaps",
"FAME": "Fame MMA",
@ -2860,6 +2892,7 @@
"FDO": "Firdaos",
"FDR": "French Digital Reserve",
"FDT": "Frutti Dino",
"FDUSD": "First Digital USD",
"FDX": "fidentiaX",
"FDZ": "Friendz",
"FEAR": "Fear",
@ -2870,6 +2903,7 @@
"FEN": "First Ever NFT",
"FENOMY": "Fenomy",
"FER": "Ferro",
"FERC": "FairERC20",
"FERMA": "Ferma",
"FESS": "Fesschain",
"FET": "Fetch.AI",
@ -2931,7 +2965,7 @@
"FLASH": "Flashstake",
"FLASHC": "FLASH coin",
"FLC": "FlowChainCoin",
"FLD": "FLUID",
"FLD": "FluidAI",
"FLDC": "Folding Coin",
"FLDT": "FairyLand",
"FLETA": "FLETA",
@ -3091,6 +3125,7 @@
"FUEL": "Jetfuel Finance",
"FUJIN": "Fujinto",
"FUKU": "Furukuru",
"FUMO": "Alien Milady Fumo",
"FUN": "FUN Token",
"FUNC": "FunCoin",
"FUND": "Unification",
@ -3101,6 +3136,7 @@
"FUNDZ": "FundFantasy",
"FUNK": "Cypherfunks Coin",
"FUR": "Furio",
"FURU": "Furucombo",
"FURY": "Engines of Fury",
"FUS": "Fus",
"FUSE": "Fuse Network Token",
@ -3118,6 +3154,7 @@
"FXP": "FXPay",
"FXS": "Frax Share",
"FXT": "FuzeX",
"FXY": "Floxypay",
"FYN": "Affyn",
"FYP": "FlypMe",
"FYZ": "Fyooz",
@ -3172,6 +3209,7 @@
"GAT": "GATCOIN",
"GATE": "GATENet",
"GATEWAY": "Gateway Protocol",
"GAYPEPE": "Gay Pepe",
"GAZE": "GazeTV",
"GB": "GoldBlocks",
"GBA": "Geeba",
@ -3222,6 +3260,7 @@
"GEMZ": "Gemz Social",
"GEN": "DAOstack",
"GENE": "Genopets",
"GENIE": "The Genie",
"GENIX": "Genix",
"GENS": "Genshiro",
"GENSTAKE": "Genstake",
@ -3261,6 +3300,7 @@
"GHCOLD": "Galaxy Heroes Coin",
"GHD": "Giftedhands",
"GHNY": "Grizzly Honey",
"GHO": "GHO",
"GHOST": "GhostbyMcAfee",
"GHOSTCOIN": "GhostCoin",
"GHOSTM": "GhostMarket",
@ -3274,6 +3314,7 @@
"GIFT": "GiftNet",
"GIG": "GigaCoin",
"GIGA": "GigaSwap",
"GIGX": "GigXCoin",
"GIM": "Gimli",
"GIMMER": "Gimmer",
"GIN": "GINcoin",
@ -3385,6 +3426,7 @@
"GOVT": "The Government Network",
"GOZ": "Göztepe S.K. Fan Token",
"GP": "Wizards And Dragons",
"GPBP": "Genius Playboy Billionaire Philanthropist",
"GPKR": "Gold Poker",
"GPL": "Gold Pressed Latinum",
"GPPT": "Pluto Project Coin",
@ -3501,7 +3543,8 @@
"HALF": "0.5X Long Bitcoin Token",
"HALFSHIT": "0.5X Long Shitcoin Index Token",
"HALLO": "Halloween Coin",
"HALO": "Halo Platform",
"HALO": "Halo Coin",
"HALOPLATFORM": "Halo Platform",
"HAM": "Hamster",
"HAMS": "HamsterCoin",
"HANA": "Hanacoin",
@ -3598,6 +3641,7 @@
"HILL": "President Clinton",
"HINA": "Hina Inu",
"HINT": "Hintchain",
"HIPPO": "HIPPO",
"HIRE": "HireMatch",
"HIT": "HitChain",
"HITBTC": "HitBTC Token",
@ -3634,6 +3678,7 @@
"HNTR": "Hunter",
"HNY": "Honey",
"HNZO": "Hanzo Inu",
"HOBO": "HOBO THE BEAR",
"HOD": "HoDooi.com",
"HODL": "HOdlcoin",
"HOGE": "Hoge Finance",
@ -3839,7 +3884,7 @@
"IMPCN": "Brain Space",
"IMPER": "Impermax",
"IMPS": "Impulse Coin",
"IMPT": "Ether Kingdoms Token",
"IMPT": "IMPT",
"IMPULSE": "IMPULSE by FDR",
"IMS": "Independent Money System",
"IMST": "Imsmart",
@ -4001,6 +4046,7 @@
"JAM": "Tune.Fm",
"JANE": "JaneCoin",
"JAR": "Jarvis+",
"JARED": "Jared From Subway",
"JASMY": "JasmyCoin",
"JBS": "JumBucks Coin",
"JBX": "Juicebox",
@ -4163,9 +4209,10 @@
"KIN": "Kin",
"KIND": "Kind Ads",
"KINE": "Kine Protocol",
"KING": "King Finance",
"KING": "KING",
"KING93": "King93",
"KINGDOMQUEST": "Kingdom Quest",
"KINGF": "King Finance",
"KINGSHIB": "King Shiba",
"KINGSWAP": "KingSwap",
"KINT": "Kintsugi",
@ -4175,6 +4222,7 @@
"KISC": "Kaiser",
"KISHIMOTO": "Kishimoto Inu",
"KISHU": "Kishu Inu",
"KITA": "KITA INU",
"KITSU": "Kitsune Inu",
"KITTY": "Kitty Inu",
"KKO": "Kineko",
@ -4267,10 +4315,12 @@
"KUBO": "KUBO",
"KUBOS": "KubosCoin",
"KUE": "Kuende",
"KUJI": "Kujira",
"KUMA": "Kuma Inu",
"KUNCI": "Kunci Coin",
"KUR": "Kuro",
"KURT": "Kurrent",
"KUSA": "Kusa Inu",
"KUSD": "Kowala",
"KUSH": "KushCoin",
"KUV": "Kuverit",
@ -4280,6 +4330,7 @@
"KVT": "Kinesis Velocity Token",
"KWATT": "4New",
"KWD": "KIWI DEFI",
"KWENTA": "Kwenta",
"KWH": "KWHCoin",
"KWIK": "KwikSwap",
"KWS": "Knight War Spirits",
@ -4299,7 +4350,9 @@
"LABX": "Stakinglab",
"LACCOIN": "LocalAgro",
"LACE": "Lovelace World",
"LADYS": "Milady Meme Coin",
"LAEEB": "LaEeb",
"LAELAPS": "Laelaps",
"LAIKA": "Laika Protocol",
"LALA": "LaLa World",
"LAMB": "Lambda",
@ -4455,13 +4508,14 @@
"LLAND": "Lyfe Land",
"LLG": "Loligo",
"LLION": "Lydian Lion",
"LM": "LM Token",
"LM": "LeisureMeta",
"LMAO": "LMAO Finance",
"LMC": "LomoCoin",
"LMCH": "Latamcash",
"LMCSWAP": "LimoCoin SWAP",
"LMR": "Lumerin",
"LMT": "Lympo Market Token",
"LMTOKEN": "LM Token",
"LMXC": "LimonX",
"LMY": "Lunch Money",
"LN": "LINK",
@ -4530,6 +4584,7 @@
"LRG": "Largo Coin",
"LRN": "Loopring [NEO]",
"LSD": "LightSpeedCoin",
"LSETH": "Liquid Staked ETH",
"LSK": "Lisk",
"LSP": "Lumenswap",
"LSS": "Lossless",
@ -4626,6 +4681,7 @@
"MAEP": "Maester Protocol",
"MAG": "Magnet",
"MAGIC": "Magic",
"MAGICF": "MagicFox",
"MAHA": "MahaDAO",
"MAI": "Mindsync",
"MAID": "MaidSafe Coin",
@ -4639,6 +4695,7 @@
"MANDOX": "MandoX",
"MANGA": "Manga Token",
"MANNA": "Manna",
"MANTLE": "Mantle",
"MAP": "MAP Protocol",
"MAPC": "MapCoin",
"MAPE": "Mecha Morphing",
@ -4672,6 +4729,7 @@
"MATIC": "Polygon",
"MATPAD": "MaticPad",
"MATTER": "AntiMatter",
"MAV": "Maverick Protocol",
"MAX": "MaxCoin",
"MAXR": "Max Revive",
"MAY": "Theresa May Coin",
@ -4776,6 +4834,7 @@
"MESA": "MetaVisa",
"MESG": "MESG",
"MESH": "MeshBox",
"MESSI": "MESSI COIN",
"MET": "Metronome",
"META": "Metadium",
"METAC": "Metacoin",
@ -4881,6 +4940,7 @@
"MIODIO": "MIODIOCOIN",
"MIOTA": "IOTA",
"MIR": "Mirror Protocol",
"MIRACLE": "MIRACLE",
"MIRC": "MIR COIN",
"MIS": "Mithril Share",
"MISA": "Sangkara",
@ -4938,7 +4998,6 @@
"MNRB": "MoneyRebel",
"MNS": "Monnos",
"MNST": "MoonStarter",
"MNT": "microNFT",
"MNTC": "Manet Coin",
"MNTG": "Monetas",
"MNTL": "AssetMantle",
@ -4967,6 +5026,7 @@
"MOF": "Molecular Future (TRC20)",
"MOFI": "MobiFi",
"MOFOLD": "Molecular Future (ERC20)",
"MOG": "Mog Coin",
"MOGU": "Mogu",
"MOGX": "Mogu",
"MOI": "MyOwnItem",
@ -4989,9 +5049,11 @@
"MONEYIMT": "MoneyToken",
"MONF": "Monfter",
"MONG": "MongCoin",
"MONG20": "Mongoose 2.0",
"MONI": "Monsta Infinite",
"MONK": "Monkey Project",
"MONKEY": "Monkey",
"MONKEYS": "Monkeys Token",
"MONO": "MonoX",
"MONONOKEINU": "Mononoke Inu",
"MONS": "Monsters Clan",
@ -5011,11 +5073,13 @@
"MOONSHOT": "Moonshot",
"MOOO": "Hashtagger",
"MOOV": "dotmoovs",
"MOOX": "Moox Protocol",
"MOPS": "Mops",
"MORA": "Meliora",
"MORE": "More Coin",
"MOS": "MOS Coin",
"MOT": "Olympus Labs",
"MOTG": "MetaOctagon",
"MOTI": "Motion",
"MOTO": "Motocoin",
"MOV": "MovieCoin",
@ -5076,6 +5140,7 @@
"MSWAP": "MoneySwap",
"MT": "MyToken",
"MTA": "Meta",
"MTB": "MetaBridge",
"MTBC": "Metabolic",
"MTC": "MEDICAL TOKEN CURRENCY",
"MTCMN": "MTC Mesh",
@ -5108,6 +5173,7 @@
"MUE": "MonetaryUnit",
"MULTI": "Multichain",
"MULTIBOT": "Multibot",
"MULTIV": "Multiverse",
"MUN": "MUNcoin",
"MUNCH": "Munch Token",
"MUSD": "mStable USD",
@ -5648,6 +5714,7 @@
"OZP": "OZAPHYRE",
"P202": "Project 202",
"P2PS": "P2P Solutions Foundation",
"PAAL": "PAAL AI",
"PAC": "PAC Protocol",
"PACOCA": "Pacoca",
"PAD": "NearPad",
@ -5736,6 +5803,7 @@
"PEARL": "Pearl Finance",
"PEC": "PeaceCoin",
"PEEL": "Meta Apes",
"PEEPA": "Peepa",
"PEEPS": "The Peoples Coin",
"PEG": "PegNet",
"PEGS": "PegShares",
@ -5748,6 +5816,7 @@
"PEOPLE": "ConstitutionDAO",
"PEOS": "pEOS",
"PEPE": "Pepe",
"PEPE20": "Pepe 2.0",
"PEPECASH": "Pepe Cash",
"PEPPER": "Pepper Token",
"PEPS": "PEPS Coin",
@ -5822,6 +5891,7 @@
"PINK": "PinkCoin",
"PINKX": "PantherCoin",
"PINMO": "Pinmo",
"PINO": "Pinocchu",
"PINU": "Piccolo Inu",
"PIO": "Pioneershares",
"PIPI": "Pippi Finance",
@ -5885,6 +5955,7 @@
"PLS": "Pulsechain",
"PLSD": "PulseDogecoin",
"PLSPAD": "PulsePad",
"PLSX": "PulseX",
"PLT": "Poollotto.finance",
"PLTC": "PlatonCoin",
"PLTX": "PlutusX",
@ -5911,7 +5982,6 @@
"PNK": "Kleros",
"PNL": "True PNL",
"PNODE": "Pinknode",
"PNP": "LogisticsX",
"PNT": "pNetwork Token",
"PNX": "PhantomX",
"PNY": "Peony Coin",
@ -5927,6 +5997,7 @@
"POINTS": "Cryptsy Points",
"POK": "Pokmonsters",
"POKEM": "Pokemonio",
"POKEMON": "Pokemon",
"POKER": "PokerCoin",
"POKT": "Pocket Network",
"POL": "Pool-X",
@ -6010,6 +6081,7 @@
"PRIME": "Echelon Prime",
"PRIMECHAIN": "PrimeChain",
"PRINT": "Printer.Finance",
"PRINTERIUM": "Printerium",
"PRINTS": "FingerprintsDAO",
"PRISM": "Prism",
"PRIX": "Privatix",
@ -6033,7 +6105,7 @@
"PROTON": "Proton",
"PROUD": "PROUD Money",
"PROXI": "PROXI",
"PRP": "Papyrus",
"PRP": "Pepe Prime",
"PRPS": "Purpose",
"PRPT": "Purple Token",
"PRQ": "PARSIQ",
@ -6042,7 +6114,7 @@
"PRTG": "Pre-Retogeum",
"PRV": "PrivacySwap",
"PRVS": "Previse",
"PRX": "Printerium",
"PRX": "Parex",
"PRXY": "Proxy",
"PRY": "PRIMARY",
"PSB": "Planet Sandbox",
@ -6120,6 +6192,7 @@
"PYRAM": "Pyram Token",
"PYRK": "Pyrk",
"PYT": "Payther",
"PYUSD": "PayPal USD",
"PZM": "Prizm",
"Q1S": "Quantum1Net",
"Q2C": "QubitCoin",
@ -6178,6 +6251,7 @@
"QUA": "Quantum Tech",
"QUACK": "Rich Quack",
"QUAM": "Quam Network",
"QUANT": "Quant Finance",
"QUARASHI": "Quarashi Network",
"QUARTZ": "Sandclock",
"QUASA": "Quasacoin",
@ -6201,7 +6275,7 @@
"RAC": "RAcoin",
"RACA": "Radio Caca",
"RACEFI": "RaceFi",
"RAD": "Radicle",
"RAD": "Radworks",
"RADAR": "DappRadar",
"RADI": "RadicalCoin",
"RADIO": "RadioShack",
@ -6220,7 +6294,7 @@
"RAM": "Ramifi Protocol",
"RAMP": "RAMP",
"RANKER": "RankerDao",
"RAP": "Rapture",
"RAP": "Philosoraptor",
"RAPDOGE": "RapDoge",
"RARE": "SuperRare",
"RARI": "Rarible",
@ -6277,6 +6351,7 @@
"REA": "Realisto",
"REAL": "RealLink",
"REALM": "Realm",
"REALMS": "Realms of Ethernity",
"REALPLATFORM": "REAL",
"REALY": "Realy Metaverse",
"REAP": "ReapChain",
@ -6287,6 +6362,7 @@
"RED": "RED TOKEN",
"REDC": "RedCab",
"REDCO": "Redcoin",
"REDDIT": "Reddit",
"REDI": "REDi",
"REDLANG": "RED",
"REDLC": "Redlight Chain",
@ -6324,7 +6400,7 @@
"REST": "Restore",
"RET": "RealTract",
"RETAIL": "Retail.Global",
"RETH": "Realms of Ethernity",
"RETH": "Rocket Pool ETH",
"RETH2": "rETH2",
"RETIRE": "Retire Token",
"REU": "REUCOIN",
@ -6351,6 +6427,7 @@
"RGP": "Rigel Protocol",
"RGT": "Rari Governance Token",
"RHEA": "Rhea",
"RHINO": "RHINO",
"RHOC": "RChain",
"RHP": "Rhypton Club",
"RIC": "Riecoin",
@ -6490,6 +6567,7 @@
"RWE": "Real-World Evidence",
"RWN": "Rowan Token",
"RWS": "Robonomics Web Services",
"RXD": "Radiant",
"RXT": "RIMAUNANGIS",
"RYC": "RoyalCoin",
"RYCN": "RoyalCoin 2.0",
@ -6564,6 +6642,7 @@
"SBTC": "Super Bitcoin",
"SC": "Siacoin",
"SCA": "SiaClassic",
"SCAM": "Scam Coin",
"SCAP": "SafeCapital",
"SCAR": "Velhalla",
"SCASH": "SpaceCash",
@ -6624,6 +6703,7 @@
"SEER": "SEER",
"SEI": "Sei",
"SEL": "SelenCoin",
"SELF": "SELFCrypto",
"SEM": "Semux",
"SEN": "Sentaro",
"SENATE": "SENATE",
@ -6665,6 +6745,7 @@
"SGE": "Society of Galactic Exploration",
"SGLY": "Singularity",
"SGN": "Signals Network",
"SGO": "SafuuGO",
"SGOLD": "SpaceGold",
"SGP": "SGPay",
"SGR": "Sogur Currency",
@ -6684,6 +6765,7 @@
"SHEESH": "Sheesh it is bussin bussin",
"SHEESHA": "Sheesha Finance",
"SHELL": "Shell Token",
"SHERA": "Shera Tokens",
"SHFL": "SHUFFLE!",
"SHFT": "Shyft Network",
"SHI": "Shirtum",
@ -6719,6 +6801,8 @@
"SHR": "ShareToken",
"SHREK": "ShrekCoin",
"SHROOM": "Shroom.Finance",
"SHROOMFOX": "Magic Shroom",
"SHS": "SHEESH",
"SHX": "Stronghold Token",
"SI": "Siren",
"SIB": "SibCoin",
@ -7018,9 +7102,11 @@
"STEN": "Steneum Coin",
"STEP": "Step Finance",
"STEPH": "Step Hero",
"STEPR": "Step",
"STEPS": "Steps",
"STERLINGCOIN": "SterlingCoin",
"STETH": "Staked Ether",
"STEWIE": "Stewie Coin",
"STEX": "STEX",
"STF": "Structure Finance",
"STFX": "STFX",
@ -7055,7 +7141,7 @@
"STR": "Sourceless",
"STRAKS": "Straks",
"STRAX": "Stratis",
"STRAY": "Animal Token",
"STRAY": "Stray Dog",
"STREAM": "STREAMIT COIN",
"STRIP": "Stripto",
"STRK": "Strike",
@ -7361,6 +7447,7 @@
"TOM": "TOM Finance",
"TOMAHAWKCOIN": "Tomahawkcoin",
"TOMB": "Tomb",
"TOMI": "tomiNet",
"TOMO": "TomoChain",
"TOMOE": "TomoChain ERC20",
"TOMS": "TomTomCoin",
@ -7385,6 +7472,7 @@
"TOTM": "Totem",
"TOWER": "Tower",
"TOWN": "Town Star",
"TOX": "INTOverse",
"TOZ": "Tozex",
"TP": "Token Swap",
"TPAD": "TrustPad",
@ -7600,6 +7688,7 @@
"UNITY": "SuperNET",
"UNIVRS": "Universe",
"UNIX": "UniX",
"UNLEASH": "UnleashClub",
"UNN": "UNION Protocol Governance Token",
"UNO": "Unobtanium",
"UNORE": "UnoRe",
@ -7673,6 +7762,7 @@
"UTT": "United Traders Token",
"UTU": "UTU Protocol",
"UUU": "U Network",
"UWU": "uwu",
"UZUMAKI": "Uzumaki Inu",
"VAB": "Vabble",
"VADER": "Vader Protocol",
@ -7695,6 +7785,7 @@
"VCF": "Valencia CF Fan Token",
"VCG": "VCGamers",
"VCK": "28VCK",
"VCORE": "VCORE",
"VDG": "VeriDocGlobal",
"VDL": "Vidulum",
"VDO": "VidioCoin",
@ -7710,6 +7801,7 @@
"VEIL": "VEIL",
"VELA": "Vela Token",
"VELO": "Velo",
"VELOD": "Velodrome Finance",
"VELOX": "Velox",
"VELOXPROJECT": "Velox",
"VEMP": "vEmpire DDAO",
@ -7782,6 +7874,7 @@
"VNT": "VNT Chain",
"VNTW": "Value Network Token",
"VNX": "VisionX",
"VNXAU": "VNX Gold",
"VNXLU": "VNX Exchange",
"VOCO": "Provoco",
"VODKA": "Vodka Token",
@ -7902,7 +7995,8 @@
"WEC": "Whole Earth Coin",
"WEGEN": "WeGen Platform",
"WELD": "Weld",
"WELL": "Well",
"WELL": "Moonwell",
"WELLTOKEN": "Well",
"WELT": "Fabwelt",
"WELUPS": "Welups Blockchain",
"WEMIX": "WEMIX",
@ -7958,6 +8052,7 @@
"WIX": "Wixlar",
"WIZ": "WIZ Protocol",
"WKD": "Wakanda Inu",
"WLD": "Worldcoin",
"WLF": "Wolfs Group",
"WLITI": "wLITI",
"WLK": "Wolk",
@ -7983,6 +8078,7 @@
"WNZ": "Winerz",
"WOA": "Wrapped Origin Axie",
"WOD": "World of Defish",
"WOID": "WORLD ID",
"WOJ": "Wojak Finance",
"WOLF": "Insanity Coin",
"WOLFILAND": "Wolfiland",
@ -8000,6 +8096,7 @@
"WOOFY": "Woofy",
"WOOL": "Wolf Game Wool",
"WOONK": "Woonkly",
"WOOO": "wooonen",
"WOOP": "Woonkly Power",
"WOP": "WorldPay",
"WORLD": "World Token",
@ -8010,6 +8107,7 @@
"WOZX": "Efforce",
"WPC": "WePiggy Coin",
"WPE": "OPES (Wrapped PE)",
"WPLS": "Wrapped Pulse",
"WPP": "Green Energy Token",
"WPR": "WePower",
"WQT": "Work Quest",
@ -8049,6 +8147,7 @@
"WZEC": "Wrapped Zcash",
"WZENIQ": "Wrapped Zeniq (ETH)",
"WZRD": "Wizardia",
"X": "AI-X",
"X2": "X2Coin",
"X2Y2": "X2Y2",
"X42": "X42 Protocol",
@ -8096,7 +8195,7 @@
"XCI": "Cannabis Industry Coin",
"XCLR": "ClearCoin",
"XCM": "CoinMetro",
"XCN": "Chain",
"XCN": "Onyxcoin",
"XCO": "XCoin",
"XCONSOL": "X-Consoles",
"XCP": "CounterParty",
@ -8365,6 +8464,7 @@
"YUANG": "Yuang Coin",
"YUCJ": "Yu Coin",
"YUCT": "Yucreat",
"YUDI": "Yudi",
"YUM": "Yumerium",
"YUMMY": "Yummy",
"YUP": "Crowdholding",

View File

@ -1,4 +1,5 @@
{
"CYBER24781": "CyberConnect",
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -50,6 +50,110 @@
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -142,6 +246,14 @@
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -438,6 +550,110 @@
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -447,7 +663,111 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
@ -489,7 +809,11 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>

View File

@ -55,7 +55,6 @@ async function bootstrap() {
app.use(HtmlTemplateMiddleware);
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
@ -63,15 +62,6 @@ async function bootstrap() {
logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log('');
if (BASE_CURRENCY) {
Logger.warn(
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
);
Logger.warn(
'Please use the currency converter in the activity dialog instead.'
);
}
});
}

View File

@ -71,6 +71,14 @@ const locales = {
'/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}`
},
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}`
},
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
}
};

View File

@ -1,5 +1,5 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_CURRENCY, DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
@ -12,10 +12,6 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: DEFAULT_CURRENCY
}),
BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }),

View File

@ -127,12 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets();
}
const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
uniqueAssets
);
const assetProfiles =
await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -147,7 +145,9 @@ export class DataGatheringService {
});
} catch (error) {
Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
`Failed to enhance data for ${symbol} (${
assetProfile.dataSource
}) by ${dataEnhancer.getName()}`,
error,
'DataGatheringService'
);

View File

@ -9,6 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService
) {
this.alphaVantage = require('alphavantage')({
this.alphaVantage = Alphavantage({
key: this.configurationService.get('ALPHA_VANTAGE_API_KEY')
});
}
@ -126,6 +127,9 @@ export class AlphaVantageService implements DataProviderInterface {
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
assetClass: undefined,
assetSubClass: undefined,
currency: bestMatch['8. currency'],
dataSource: this.getName(),
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']

View File

@ -1,10 +1,13 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
@ -20,14 +23,9 @@ import got from 'got';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public constructor() {}
public canHandle(symbol: string) {
return true;
@ -39,13 +37,22 @@ export class CoinGeckoService implements DataProviderInterface {
const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
currency: DEFAULT_CURRENCY,
dataSource: this.getName(),
symbol: aSymbol
};
try {
const { name } = await got(`${this.URL}/coins/${aSymbol}`).json<any>();
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
response.name = name;
} catch (error) {
@ -78,12 +85,22 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { prices } = await got(
`${
this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
}/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
from
)}&to=${getUnixTime(to)}`
)}&to=${getUnixTime(to)}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
const result: {
@ -127,19 +144,29 @@ export class CoinGeckoService implements DataProviderInterface {
}
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join(
','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = {
currency: this.baseCurrency,
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open'
};
}
@ -165,9 +192,16 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = [];
try {
const { coins } = await got(
`${this.URL}/search?query=${query}`
).json<any>();
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { coins } = await got(`${this.URL}/search?query=${query}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
items = coins.map(({ id: symbol, name }) => {
return {
@ -175,7 +209,7 @@ export class CoinGeckoService implements DataProviderInterface {
symbol,
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
currency: DEFAULT_CURRENCY,
dataSource: this.getName()
};
});

View File

@ -4,14 +4,18 @@ import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-p
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common';
import { DataEnhancerService } from './data-enhancer.service';
@Module({
exports: [
'DataEnhancers',
DataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
YahooFinanceDataEnhancerService,
'DataEnhancers'
],
imports: [ConfigurationModule, CryptocurrencyModule],
providers: [
DataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
{

View File

@ -0,0 +1,44 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class DataEnhancerService {
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[]
) {}
public async enhance(aName: string) {
const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => {
return dataEnhancer.getName() === aName;
});
if (!dataEnhancer) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
try {
const assetProfile = await dataEnhancer.enhance({
response: {
assetClass: 'EQUITY',
assetSubClass: 'ETF'
},
symbol: dataEnhancer.getTestSymbol()
});
if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) {
return true;
}
} catch {}
return false;
}
}

View File

@ -1,4 +1,5 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
@ -7,7 +8,7 @@ import got from 'got';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com';
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
@ -32,30 +33,82 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
let abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.'
)?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
});
});
const isin = profile.isin?.split(';')?.[0];
const isin = profile?.isin?.split(';')?.[0];
if (isin) {
response.isin = isin;
}
abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`
);
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
'.'
)?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
});
});
if (holdings?.weight < 0.95) {
@ -114,4 +167,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getName() {
return 'TRACKINSIGHT';
}
public getTestSymbol() {
return 'QQQ';
}
}

View File

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
);
describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService
);
});

View File

@ -1,13 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
SymbolProfile
} from '@prisma/client';
import { countries } from 'countries-list';
@ -16,23 +16,18 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
) {}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
new RegExp(`-${DEFAULT_CURRENCY}$`),
DEFAULT_CURRENCY
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) {
symbol = `${DEFAULT_CURRENCY}${symbol}`;
}
return symbol.replace('=X', '');
@ -47,21 +42,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
aSymbol.includes(DEFAULT_CURRENCY) &&
aSymbol.length > DEFAULT_CURRENCY.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY)
)
) {
// Add a dash before the last three characters
@ -69,8 +61,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
new RegExp(`-?${DEFAULT_CURRENCY}$`),
`-${DEFAULT_CURRENCY}`
);
}
}
@ -99,15 +91,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
yahooSymbol = quotes[0].symbol;
}
const { countries, sectors, url } = await this.getAssetProfile(
yahooSymbol
);
const { countries, sectors, url } =
await this.getAssetProfile(yahooSymbol);
if (countries) {
if ((countries as unknown as Prisma.JsonArray)?.length > 0) {
response.countries = countries;
}
if (sectors) {
if ((sectors as unknown as Prisma.JsonArray)?.length > 0) {
response.sectors = sectors;
}
@ -234,6 +225,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return DataSource.YAHOO;
}
public getTestSymbol() {
return 'AAPL';
}
public parseAssetClass({
quoteType,
shortName

View File

@ -5,7 +5,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -18,19 +21,16 @@ import {
import Big from 'big.js';
import { format, isToday } from 'date-fns';
import got from 'got';
import ms from 'ms';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
@ -78,6 +78,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
const symbol = this.convertToEodSymbol(aSymbol);
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/eod/${symbol}?api_token=${
this.apiKey
@ -86,9 +92,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
DATE_FORMAT
)}&period={aGranularity}`,
{
timeout: {
request: DEFAULT_REQUEST_TIMEOUT
}
// @ts-ignore
signal: abortController.signal
}
).json<any>();
@ -138,14 +143,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey
}&fmt=json&s=${symbols.join(',')}`,
{
timeout: {
request: DEFAULT_REQUEST_TIMEOUT
}
// @ts-ignore
signal: abortController.signal
}
).json<any>();
@ -176,7 +186,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency;
result[this.convertFromEodSymbol(code)] = {
currency: currency ?? this.baseCurrency,
currency: currency ?? DEFAULT_CURRENCY,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -187,24 +197,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
{}
);
if (response[`${this.baseCurrency}GBP`]) {
response[`${this.baseCurrency}GBp`] = {
...response[`${this.baseCurrency}GBP`],
currency: `${this.baseCurrency}GBp`,
if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${DEFAULT_CURRENCY}GBp`,
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`,
value: response[`${this.baseCurrency}GBP`].marketPrice
symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
})
};
}
if (response[`${this.baseCurrency}ILS`]) {
response[`${this.baseCurrency}ILA`] = {
...response[`${this.baseCurrency}ILS`],
currency: `${this.baseCurrency}ILA`,
if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${DEFAULT_CURRENCY}ILA`,
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`,
value: response[`${this.baseCurrency}ILS`].marketPrice
symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
})
};
}
@ -273,7 +283,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
symbol = `${this.baseCurrency}${symbol}`;
symbol = `${DEFAULT_CURRENCY}${symbol}`;
}
return symbol;
@ -286,17 +296,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
*/
private convertToEodSymbol(aSymbol: string) {
if (
aSymbol.startsWith(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
aSymbol.startsWith(DEFAULT_CURRENCY) &&
aSymbol.length > DEFAULT_CURRENCY.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
return `${aSymbol
.replace('GBp', 'GBX')
.replace(this.baseCurrency, '')}.FOREX`;
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
}
}
@ -310,10 +320,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
symbol: string;
value: number;
}) {
if (symbol === `${this.baseCurrency}GBp`) {
if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
} else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA
return new Big(value).mul(100).toNumber();
}
@ -331,12 +341,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
let searchResult = [];
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
{
timeout: {
request: DEFAULT_REQUEST_TIMEOUT
}
// @ts-ignore
signal: abortController.signal
}
).json<any>();

View File

@ -5,6 +5,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
@ -16,7 +20,6 @@ import got from 'got';
@Injectable()
export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3';
public constructor(
@ -25,7 +28,6 @@ export class FinancialModelingPrepService implements DataProviderInterface {
this.apiKey = this.configurationService.get(
'FINANCIAL_MODELING_PREP_API_KEY'
);
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
@ -64,8 +66,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
const result: {
@ -111,13 +123,23 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
for (const { price, symbol } of response) {
results[symbol] = {
currency: this.baseCurrency,
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price,
@ -145,8 +167,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = [];
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
items = result.map(({ currency, name, symbol }) => {

View File

@ -10,4 +10,6 @@ export interface DataEnhancerInterface {
}): Promise<Partial<SymbolProfile>>;
getName(): string;
getTestSymbol(): string;
}

View File

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
extractNumberFromString,
@ -95,7 +96,17 @@ export class ManualService implements DataProviderInterface {
return {};
}
const { body } = await got(url, { headers });
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);

View File

@ -5,7 +5,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import {
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -135,6 +138,12 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string };
}> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
{
@ -142,7 +151,9 @@ export class RapidApiService implements DataProviderInterface {
useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
}
},
// @ts-ignore
signal: abortController.signal
}
).json<any>();

View File

@ -1,5 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -7,6 +6,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -18,15 +18,10 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
) {}
public canHandle(symbol: string) {
return true;
@ -212,50 +207,50 @@ export class YahooFinanceService implements DataProviderInterface {
};
if (
symbol === `${this.baseCurrency}GBP` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`)
symbol === `${DEFAULT_CURRENCY}GBP` &&
yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`)
) {
// Convert GPB to GBp (pence)
response[`${this.baseCurrency}GBp`] = {
response[`${DEFAULT_CURRENCY}GBp`] = {
...response[symbol],
currency: 'GBp',
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`,
symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[symbol].marketPrice
})
};
} else if (
symbol === `${this.baseCurrency}ILS` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`)
symbol === `${DEFAULT_CURRENCY}ILS` &&
yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`)
) {
// Convert ILS to ILA
response[`${this.baseCurrency}ILA`] = {
response[`${DEFAULT_CURRENCY}ILA`] = {
...response[symbol],
currency: 'ILA',
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`,
symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[symbol].marketPrice
})
};
} else if (
symbol === `${this.baseCurrency}ZAR` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`)
symbol === `${DEFAULT_CURRENCY}ZAR` &&
yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`)
) {
// Convert ZAR to ZAc (cents)
response[`${this.baseCurrency}ZAc`] = {
response[`${DEFAULT_CURRENCY}ZAc`] = {
...response[symbol],
currency: 'ZAc',
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ZAc`,
symbol: `${DEFAULT_CURRENCY}ZAc`,
value: response[symbol].marketPrice
})
};
}
}
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) {
if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) {
// Convert USD to USX (cent)
response[`${this.baseCurrency}USX`] = {
response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
@ -303,8 +298,8 @@ export class YahooFinanceService implements DataProviderInterface {
(quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency(
symbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
new RegExp(`-${DEFAULT_CURRENCY}$`),
DEFAULT_CURRENCY
)
)) ||
quoteTypes.includes(quoteType)
@ -314,7 +309,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before
return symbol.includes(this.baseCurrency);
return symbol.includes(DEFAULT_CURRENCY);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;
@ -373,13 +368,13 @@ export class YahooFinanceService implements DataProviderInterface {
symbol: string;
value: number;
}) {
if (symbol === `${this.baseCurrency}GBp`) {
if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
} else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
} else if (symbol === `${DEFAULT_CURRENCY}ZAc`) {
// Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber();
}

View File

@ -1,10 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns';
@ -12,13 +14,11 @@ import { isNumber, uniq } from 'lodash';
@Injectable()
export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -26,7 +26,7 @@ export class ExchangeRateDataService {
) {}
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY];
}
public getCurrencyPairs() {
@ -43,7 +43,6 @@ export class ExchangeRateDataService {
}
public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies();
this.currencyPairs = [];
this.exchangeRates = {};
@ -113,9 +112,9 @@ export class ExchangeRateDataService {
if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via base currency
this.exchangeRates[symbol] =
resultExtended[`${currency1}${this.baseCurrency}`]?.[date]
resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date]
?.marketPrice *
resultExtended[`${this.baseCurrency}${currency2}`]?.[date]
resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date]
?.marketPrice;
// Calculate the opposite direction
@ -144,9 +143,8 @@ export class ExchangeRateDataService {
} else {
// Calculate indirectly via base currency
const factor1 =
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`];
const factor2 =
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`];
const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`];
factor = factor1 * factor2;
@ -204,28 +202,28 @@ export class ExchangeRateDataService {
let marketPriceBaseCurrencyToCurrency: number;
try {
if (this.baseCurrency === aFromCurrency) {
if (aFromCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyFromCurrency = 1;
} else {
marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
symbol: `${DEFAULT_CURRENCY}${aFromCurrency}`
})
)?.marketPrice;
}
} catch {}
try {
if (this.baseCurrency === aToCurrency) {
if (aToCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyToCurrency = 1;
} else {
marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
symbol: `${DEFAULT_CURRENCY}${aToCurrency}`
})
)?.marketPrice;
}
@ -295,14 +293,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies
.filter((currency) => {
return currency !== this.baseCurrency;
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
return {
currency1: this.baseCurrency,
currency1: DEFAULT_CURRENCY,
currency2: currency,
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
symbol: `${this.baseCurrency}${currency}`
symbol: `${DEFAULT_CURRENCY}${currency}`
};
});
}

View File

@ -3,7 +3,6 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;

View File

@ -65,9 +65,8 @@ export class TwitterBotService {
status += benchmarkListing;
}
const { data: createdTweet } = await this.twitterClient.v2.tweet(
status
);
const { data: createdTweet } =
await this.twitterClient.v2.tweet(status);
Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,

View File

@ -1,37 +1,26 @@
<header>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<div
*ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container"
class="info-message-container"
>
<div class="row">
<div class="col-md-8 offset-md-2 text-center">
<div class="info-message-inner-container position-fixed w-100">
<div class="align-items-center d-flex h-100 justify-content-center">
<a
*ngIf="canCreateAccount"
class="text-center"
[routerLink]="routerLinkRegister"
>
<div
class="cursor-pointer d-inline-block info-message px-3 py-2"
class="cursor-pointer d-inline-block info-message"
(click)="onCreateAccount()"
>
<span>You are using the Live Demo.</span>
<span class="a ml-2">Create Account</span>
<span i18n>You are using the Live Demo.</span>
<span class="a ml-2" i18n>Create Account</span>
</div></a
>
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate"
class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()"
>
{{ info.systemMessage }}
@ -40,6 +29,18 @@
</div>
</div>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<router-outlet></router-outlet>
</main>

View File

@ -4,31 +4,47 @@
display: block;
min-height: 100vh;
&.has-info-message {
header {
height: calc(2 * var(--mat-toolbar-standard-height));
.info-message-container {
height: var(--mat-toolbar-standard-height);
.info-message-inner-container {
background-color: rgba(var(--palette-primary-500), 1);
height: var(--mat-toolbar-standard-height);
z-index: 999;
.info-message {
color: rgba(var(--palette-foreground-text), 1);
font-size: 80%;
max-width: 100%;
.a {
font-weight: 500;
}
}
}
}
}
main {
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
}
}
footer {
background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%;
}
header {
height: var(--mat-toolbar-standard-height);
}
main {
min-height: 100vh;
padding-top: 5rem;
.info-message-container {
height: 3.5rem;
margin-top: -0.5rem;
.info-message {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 2rem;
font-size: 80%;
max-width: 100%;
.a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
}
}
}
min-height: calc(100vh - var(--mat-toolbar-standard-height));
}
}
@ -36,12 +52,4 @@
footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
main {
.info-message-container {
.info-message {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
}
}
}

View File

@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostBinding,
Inject,
OnDestroy,
OnInit
@ -28,14 +29,20 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnDestroy, OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean;
public currentRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
@ -103,6 +110,14 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||
this.currentRoute === 'zen') &&
this.deviceType !== 'mobile';
this.showFooter =
(this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
@ -140,6 +155,12 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.createUserAccount
);
this.hasInfoMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck();

View File

@ -1,3 +1,15 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@ -19,6 +19,7 @@ import { Access } from '@ghostfolio/common/interfaces';
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>();

View File

@ -3,13 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AccessTableComponent } from './access-table.component';
@NgModule({
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
imports: [
CommonModule,
MatButtonModule,
MatMenuModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioAccessTableModule {}

View File

@ -29,13 +29,13 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: string;
public balance: number;
public currency: string;
public equity: number;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public transactionCount: number;
public user: User;
public valueInBaseCurrency: number;
@ -65,15 +65,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
accountType,
balance,
currency,
name,
Platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.accountType = translate(accountType);
this.balance = balance;
this.currency = currency;
@ -85,6 +84,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.name = name;
this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();

View File

@ -44,8 +44,8 @@
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="accountType"
>Account Type</gf-value
<gf-value i18n size="medium" [value]="transactionCount"
>Activities</gf-value
>
</div>
<div class="col-6 mb-3">

View File

@ -85,7 +85,7 @@
<ng-container matColumnDef="transactions">
<th
*matHeaderCellDef
class="px-1 text-right"
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="transactionCount"
>
@ -93,9 +93,7 @@
<span class="d-none d-sm-block" i18n>Activities</span>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
element.transactionCount
}}</ng-container>
{{ element.transactionCount }}
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }}

View File

@ -29,7 +29,7 @@
}"
[title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date : defaultDateFormat) ?? ''
| date: defaultDateFormat) ?? ''
"
(click)="
onOpenMarketDataDetail({

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-admin-settings',
styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html'

View File

@ -1,14 +1,17 @@
<mat-toolbar class="px-2">
<mat-toolbar class="px-0">
<ng-container *ngIf="user">
<a
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
mat-button
[routerLink]="['/']"
>
<gf-logo [label]="pageTitle"></gf-logo>
</a>
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
>
<gf-logo class="px-2" [label]="pageTitle"></gf-logo>
</a>
</div>
<span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0">
<ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
@ -26,7 +29,7 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -39,7 +42,7 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -52,7 +55,7 @@
</li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -65,7 +68,7 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -83,7 +86,7 @@
class="list-inline-item"
>
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -96,7 +99,7 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -129,33 +132,37 @@
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
<span class="align-items-center d-flex">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
</span>
</button>
<button
*ngFor="let accessItem of user?.access"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
<span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</span>
</button>
<hr class="m-0" />
</ng-container>
@ -242,21 +249,25 @@
</ul>
</ng-container>
<ng-container *ngIf="user === null">
<a
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
mat-button
[routerLink]="['/']"
>
<gf-logo
[label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
></gf-logo>
</a>
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
>
<gf-logo
class="px-2"
[label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
></gf-logo>
</a>
</div>
<span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0">
<ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -269,7 +280,7 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -282,6 +293,7 @@
</li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a
class="d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -297,7 +309,7 @@
class="list-inline-item"
>
<a
class="d-none d-sm-block mx-1"
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
@ -317,13 +329,13 @@
></a>
</li>
<li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
</li>
<li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item"
class="list-inline-item ml-1"
>
<a
class="d-none d-sm-block"

View File

@ -7,8 +7,18 @@
.mat-toolbar {
background-color: var(--light-background);
.spacer {
flex: 1 1 auto;
.logo-container {
&.filled {
background-color: rgba(var(--palette-foreground-base), 0.02);
}
@media (min-width: 576px) {
width: 14rem;
}
}
.list-inline-item {
margin: 0;
}
.mdc-button {
@ -24,11 +34,21 @@
font-size: 1.5rem;
}
}
.spacer {
flex: 1 1 auto;
}
}
}
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: var(--dark-background);
.logo-container {
&.filled {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
}
}
}

View File

@ -29,6 +29,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
})
export class HeaderComponent implements OnChanges {
@Input() currentRoute: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@Input() user: User;

View File

@ -0,0 +1,28 @@
import { Directive, HostListener, Output, EventEmitter } from '@angular/core';
@Directive({
selector: '[gfFileDrop]'
})
export class FileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('drop', ['$event']) onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
}
}

View File

@ -0,0 +1,9 @@
import { NgModule } from '@angular/core';
import { FileDropDirective } from './file-drop.directive';
@NgModule({
declarations: [FileDropDirective],
exports: [FileDropDirective]
})
export class GfFileDropModule {}

View File

@ -1,10 +1,11 @@
import * as path from 'path';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { paths } from '@ghostfolio/client/app-routing.module';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
import { paths } from '@ghostfolio/client/app-routing.module';
import * as path from 'path';
const routes: Routes = [
{

View File

@ -1,28 +1,20 @@
import {
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page has-tabs' },
selector: 'gf-about-page',
styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() {
return this.hasMessage;
}
public hasMessage: boolean;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = [];
public user: User;
@ -32,9 +24,10 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
const { globalPermissions, systemMessage } = this.dataService.fetchInfo();
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
@ -71,12 +64,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
});
this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck();
}
@ -88,7 +75,9 @@ export class AboutPageComponent implements OnDestroy, OnInit {
});
}
public ngOnInit() {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs">
<a
#rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>

View File

@ -2,27 +2,6 @@
:host {
color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
}
:host-context(.is-dark-theme) {

View File

@ -2,7 +2,6 @@ import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-changelog-page',
styleUrls: ['./changelog-page.scss'],
templateUrl: './changelog-page.html'

View File

@ -2,7 +2,6 @@ import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-license-page',
styleUrls: ['./license-page.scss'],
templateUrl: './license-page.html'

View File

@ -1,138 +1,15 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
const ossFriends = require('../../../../assets/oss-friends.json');
@Component({
host: { class: 'page' },
selector: 'gf-oss-friends-page',
styleUrls: ['./oss-friends-page.scss'],
templateUrl: './oss-friends-page.html'
})
export class OpenSourceSoftwareFriendsPageComponent implements OnDestroy {
public ossFriends = [
{
description: 'Build custom software on top of your data.',
name: 'Appsmith',
url: 'https://www.appsmith.com'
},
{
description:
'BoxyHQs suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.',
name: 'BoxyHQ',
url: 'https://boxyhq.com'
},
{
description:
'Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.',
name: 'Cal.com',
url: 'https://cal.com'
},
{
description:
'Centralize community, product, and customer data to understand which companies are engaging with your open source project.',
name: 'Crowd.dev',
url: 'https://www.crowd.dev'
},
{
description:
'The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.',
name: 'Documenso',
url: 'https://documenso.com'
},
{
description:
'The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.',
name: 'Erxes',
url: 'https://erxes.io'
},
{
description:
'Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.',
name: 'Formbricks',
url: 'https://formbricks.com'
},
{
description:
'GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.',
name: 'GitWonk',
url: 'https://gitwonk.com'
},
{
description:
'Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.',
name: 'Hanko',
url: 'https://www.hanko.io'
},
{
description:
'HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.',
name: 'HTMX',
url: 'https://htmx.org'
},
{
description:
'Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.',
name: 'Infisical',
url: 'https://infisical.com'
},
{
description:
'Mockoon is the easiest and quickest way to design and run mock REST APIs.',
name: 'Mockoon',
url: 'https://mockoon.com'
},
{
description:
'The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.',
name: 'Novu',
url: 'https://novu.co'
},
{
description:
'Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.',
name: 'OpenBB',
url: 'https://openbb.co'
},
{
description:
'Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.',
name: 'Sniffnet',
url: 'https://www.sniffnet.net'
},
{
description: 'Software localization from A to Z made really easy.',
name: 'Tolgee',
url: 'https://tolgee.io'
},
{
description:
'Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.',
name: 'Trigger.dev',
url: 'https://trigger.dev'
},
{
description:
'Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.',
name: 'Typebot',
url: 'https://typebot.io'
},
{
description:
'A modern CRM offering the flexibility of open-source, advanced features and sleek design.',
name: 'Twenty',
url: 'https://twenty.com'
},
{
description:
'Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.',
name: 'Webiny',
url: 'https://www.webiny.com'
},
{
description: 'Webstudio is an open source alternative to Webflow',
name: 'Webstudio',
url: 'https://webstudio.is'
}
];
public ossFriends = ossFriends.data;
private unsubscribeSubject = new Subject<void>();

View File

@ -14,26 +14,27 @@
*ngFor="let ossFriend of ossFriends"
class="col-xs-12 col-md-4 mb-3"
>
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-header>
<mat-card-title class="h4"
><a target="_blank" [href]="ossFriend.url"
>{{ ossFriend.name }}</a
></mat-card-title
>
</mat-card-header>
<mat-card-content class="flex-grow-1">
<p>{{ ossFriend.description }}</p>
</mat-card-content>
<mat-card-actions class="justify-content-end">
<a mat-button target="_blank" [href]="ossFriend.url">
<span
><ng-container i18n>Visit</ng-container> {{ ossFriend.name
}}</span
><ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</mat-card-actions>
</mat-card>
<a target="_blank" [href]="ossFriend.href">
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-header>
<mat-card-title class="h4">{{ ossFriend.name }}</mat-card-title>
</mat-card-header>
<mat-card-content class="flex-grow-1">
<p>{{ ossFriend.description }}</p>
</mat-card-content>
<mat-card-actions class="justify-content-end">
<a mat-button target="_blank" [href]="ossFriend.href">
<span
><ng-container i18n>Visit</ng-container> {{ ossFriend.name
}}</span
><ion-icon
class="ml-1"
name="arrow-forward-outline"
></ion-icon>
</a>
</mat-card-actions>
</mat-card>
</a>
</div>
</div>
</div>

View File

@ -1,10 +1,10 @@
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 { OpenSourceSoftwareFriendsPageRoutingModule } from './oss-friends-page-routing.module';
import { OpenSourceSoftwareFriendsPageComponent } from './oss-friends-page.component';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [OpenSourceSoftwareFriendsPageComponent],

View File

@ -1,3 +1,9 @@
:host {
display: block;
.mat-mdc-card {
&:hover {
border-color: var(--gf-theme-primary-500);
}
}
}

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
selector: 'gf-about-overview-page',
styleUrls: ['./about-overview-page.scss'],
templateUrl: './about-overview-page.html'

View File

@ -1,7 +1,9 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center">About Ghostfolio</h1>
<h1 class="d-none d-sm-block h3 mb-4 text-center">
<ng-container i18n>About Ghostfolio</ng-container>
</h1>
<div class="about-container">
<p>
Ghostfolio is a lightweight wealth management application for

View File

@ -2,7 +2,6 @@ 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'

View File

@ -10,7 +10,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client';
import { Account as AccountModel } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -151,7 +151,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}
public openUpdateAccountDialog({
accountType,
balance,
comment,
currency,
@ -163,7 +162,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
accountType,
balance,
comment,
currency,
@ -232,7 +230,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
accountType: AccountType.SECURITIES,
balance: 0,
comment: null,
currency: this.user?.settings?.baseCurrency,

View File

@ -8,15 +8,6 @@
<input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option i18n value="CASH">Cash</mat-option>
<mat-option i18n value="SECURITIES">Securities</mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>

View File

@ -1,30 +1,25 @@
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { TabConfiguration } from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page has-tabs' },
selector: 'gf-admin-page',
styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html'
})
export class AdminPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() {
return this.hasMessage;
}
public hasMessage: boolean;
public deviceType: string;
public tabs: TabConfiguration[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(private dataService: DataService) {
const { systemMessage } = this.dataService.fetchInfo();
this.hasMessage = !!systemMessage;
}
public constructor(private deviceService: DeviceDetectorService) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.tabs = [
{
iconName: 'reader-outline',

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs">
<a
#rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>

View File

@ -2,27 +2,6 @@
:host {
color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
}
:host-context(.is-dark-theme) {

View File

@ -10,7 +10,7 @@
<div class="mb-3 text-muted"><small>2023-07-01</small></div>
<img
alt="Exploring the Path to Financial Independence and Retiring Early (FIRE) Teaser"
class="border rounded w-100"
class="rounded w-100"
src="../assets/images/blog/20230701.jpg"
title="Exploring the Path to Financial Independence and Retiring Early (FIRE)"
/>

View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-joins-oss-friends-page',
standalone: true,
templateUrl: './ghostfolio-joins-oss-friends-page.html'
})
export class GhostfolioJoinsOssFriendsPageComponent {
public routerLinkAboutOssFriends = ['/' + $localize`about`, 'oss-friends'];
}

View File

@ -0,0 +1,167 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio joins OSS Friends</h1>
<div class="mb-3 text-muted"><small>2023-08-23</small></div>
<img
alt="Ghostfolio joins OSS Friends Teaser"
class="rounded w-100"
src="../assets/images/blog/ghostfolio-joins-oss-friends.png"
title="Ghostfolio joins OSS Friends"
/>
</div>
<section class="mb-4">
<p>
We are excited to announce that Ghostfolio is now part of the
<a [routerLink]="routerLinkAboutOssFriends">OSS Friends</a>. This
new initiative is all about helping open source projects grow and
become more popular.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The Story of OSS Friends</h2>
<p>
OSS Friends started as a simple
<a
href="https://twitter.com/formbricks/status/1660735970281508878"
target="_blank"
>post</a
>
on X (formerly known as <i>Twitter</i>). The idea came from
<a href="https://formbricks.com" target="_blank">Formbricks</a>, an
open source experience management platform to create surveys in
minutes, and is all about giving open source projects a boost.
</p>
<p>
If you are excited about the OSS Friends movement and want to bring
your own open source project along, just take a moment to fill out
<a
href="https://app.formbricks.com/s/clhys1p9r001cpr0hu65rwh17"
target="_blank"
>this form</a
>. Lets work and learn together all the open source way.
</p>
</section>
<section class="mb-4">
<h2 class="h4">
Ghostfolio Next Generation Software for your Personal Finances
</h2>
<p>
Money management can be tricky, especially when you have various
investments like cryptocurrencies, ETFs and stocks in your
portfolio. But guess what? There are cooler ways than staring at
boring spreadsheets. Say hello to Ghostfolio, a privacy-first, open
source dashboard for your personal finances.
</p>
</section>
<section class="mb-4 py-3">
<h2 class="h4 mb-0 text-center">
Would you like to simplify asset tracking?
</h2>
<p class="lead mb-2 text-center">
Ghostfolio empowers you to make informed investment decisions.
</p>
<div class="text-center">
<a color="primary" href="https://ghostfol.io" mat-flat-button>
Get Started
</a>
</div>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Asset</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Dashboard</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Initiative</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Innovation</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS Friends</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Platform</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Tracking</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio joins OSS Friends
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-2-page',
standalone: true,
templateUrl: './ghostfolio-2-page.html'
})
export class Ghostfolio2PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
}

View File

@ -0,0 +1,286 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio 2.0</h1>
<div class="mb-3 text-muted"><small>2023-09-09</small></div>
<img
alt="Ghostfolio 2.0 Teaser"
class="border rounded w-100"
src="../assets/images/blog/ghostfolio-2.jpg"
title="Announcing Ghostfolio 2.0"
/>
</div>
<section class="mb-4">
<p>
Since late 2020, when
<a [routerLink]="routerLinkAbout">Ghostfolio</a> took shape, the
main goal has remained the same: to simplify investment tracking
while prioritizing user privacy and enable investors to make
informed decisions. Our journey so far has been incredible, with
over 300 releases since the
<a href="../en/blog/2021/07/hello-ghostfolio">first major release</a
>, close to 300000 pulls on Docker Hub, and collaboration with 50+
contributors from around the globe. Ghostfolio was recently featured
on
<a
href="https://news.ycombinator.com/item?id=37337482"
target="_blank"
>Hacker News</a
>, where it ranked on the front page and briefly hit the #1 spot.
Shortly after, the projects repository was trending on GitHub.
These achievements emphasize the growing recognition for our project
and the path we are on.
</p>
<p>
Now, we are thrilled to present Ghostfolio 2.0, another milestone in
our journey to empower investors.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Introducing Ghostfolio 2.0</h2>
<p>
Ghostfolio 2.0 is the evolution of our
<a [routerLink]="routerLinkFeatures"
>open source wealth management software</a
>, elevating both user and developer experiences. We have extended
data import capabilities, added comprehensive analytics, increased
stability, and utilized the latest technology to deliver these
improvements. Here is a closer look at a selection of the
improvements you can expect from this
<a [routerLink]="routerLinkAboutChangelog">release</a>, alongside
uncounted smaller additions and enhancements.
</p>
<h3 class="h5">Extended Data Import Capabilities</h3>
<p>
Importing account activities is an important aspect of any portfolio
management software. With Ghostfolio 2.0, we have extended our data
import functionality, ensuring a seamless experience for users. Our
system supports multiple formats to make the experience more
seamless. Additionally, there is an API, providing you with even
greater flexibility and control over how you import transactions.
</p>
<h3 class="h5">Comprehensive Analytics</h3>
<p>
Understanding your wealth is key. The latest release offers more
comprehensive analytics to categorize your securities, providing
invaluable insights into your investment portfolio for making
informed decisions and optimizing diversification.
</p>
<h3 class="h5">Reliable Stability</h3>
<p>
Ensuring the stability of software is crucial, and a platform for
managing your wealth is no exception. The increased robustness of
our architecture means that you can count on Ghostfolio to be there
when you need it most, no matter the
<a [routerLink]="routerLinkMarkets">market conditions</a>.
</p>
<h3 class="h5">Cutting-Edge Technology Stack</h3>
<p>
Ghostfolio 2.0 leverages the latest tech stack to deliver a superior
user and developer experience. We have upgraded to
<a href="https://angular.io" target="_blank">Angular 16</a>,
<a href="https://nestjs.com" target="_blank">Nest.js 10</a>,
<a href="https://www.prisma.io" target="_blank">Prisma 5</a>, and
<a href="https://nx.dev" target="_blank">Nx 16</a>, ensuring that
the software is at the forefront of innovation. This upgrade allows
us to provide you with the best possible user experience, making
investment tracking more intuitive than ever before.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Thriving Ghostfolio Community</h2>
<p>
In open source software (OSS) projects like Ghostfolio, the
community is the driving force behind its success. Without the
incredible support of our users and contributors, it would not have
been possible. As we celebrate the launch of Ghostfolio 2.0, we are
delighted to showcase the growth of the Ghostfolio community:
</p>
<ul>
<li>
Ghostfolio has accumulated <strong>2500+ stars</strong> on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>, highlighting the appreciation and adoption of our platform by
the community.
</li>
<li>
Our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community has expanded to over <strong>350 members</strong>,
creating a space for like-minded investors to connect, share
insights, and collaborate.
</li>
<li>
On
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a>
(formerly Twitter), over
<strong>300 investors and personal finance enthusiasts</strong>
follow Ghostfolio, keen to stay updated on the latest
developments.
</li>
</ul>
<p>
This is just the beginning. Ghostfolio is dedicated to ongoing
improvement and helping grow a vibrant community of investors. We
invite you to join us on this exciting journey.
</p>
<p>
<strong>Join our Slack community</strong>: Connect with fellow
investors, share your insights, and stay updated on the latest news
by joining our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community. It is a dynamic space where you can learn, collaborate,
and grow together with us.
</p>
<p>
<strong>Follow us on X</strong>: For release updates and market
insights, follow
<a href="https://twitter.com/ghostfolio_" target="_blank"
>Ghostfolio on X</a
>. It is the perfect place to stay informed and connect with our
team.
</p>
<p>
<strong>Give us a Star</strong>: If you have found value in
Ghostfolio or appreciate our commitment to simplifying investment
tracking, please consider giving us a star on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>. Your support helps us reach a wider audience and make a
difference in the world of wealth management.
</p>
<p>
<strong>Become a contributor</strong>: If you are a developer
passionate about open source projects and personal finance, we
welcome your contributions.
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
>Join our developer community</a
>, collaborate with like-minded people, and help shape the future of
Ghostfolio.
</p>
</section>
<section>
<p>
Ghostfolio 2.0 represents a major step forward in our mission to
empower investors, and we could not be more excited about the future
of the project. Together, we can build an outstanding tool that
makes our lives easier. Thank you for being a part of the Ghostfolio
community.
</p>
<p>Thomas from Ghostfolio</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Announcement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Contribution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Evolution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio 2.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nest.js</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Platform</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Release</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stack</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Announcing Ghostfolio 2.0
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -136,6 +136,24 @@ const routes: Routes = [
'./2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component'
).then((c) => c.ExploringThePathToFirePageComponent),
title: 'Exploring the Path to FIRE'
},
{
canActivate: [AuthGuard],
path: '2023/08/ghostfolio-joins-oss-friends',
loadComponent: () =>
import(
'./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component'
).then((c) => c.GhostfolioJoinsOssFriendsPageComponent),
title: 'Ghostfolio joins OSS Friends'
},
{
canActivate: [AuthGuard],
path: '2023/09/ghostfolio-2',
loadComponent: () =>
import('./2023/09/ghostfolio-2/ghostfolio-2-page.component').then(
(c) => c.Ghostfolio2PageComponent
),
title: 'Ghostfolio 2.0'
}
];

View File

@ -8,6 +8,58 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/09/ghostfolio-2"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Announcing Ghostfolio 2.0
</div>
<div class="d-flex text-muted">2023-09-09</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/08/ghostfolio-joins-oss-friends"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Ghostfolio joins OSS Friends
</div>
<div class="d-flex text-muted">2023-08-23</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">

View File

@ -1,28 +1,20 @@
import {
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page has-tabs' },
selector: 'gf-home-page',
styleUrls: ['./home-page.scss'],
templateUrl: './home-page.html'
})
export class HomePageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() {
return this.hasMessage;
}
public hasMessage: boolean;
public deviceType: string;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public tabs: TabConfiguration[] = [];
public user: User;
@ -32,9 +24,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
const { globalPermissions, systemMessage } = this.dataService.fetchInfo();
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
globalPermissions,
@ -70,18 +63,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
];
this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs">
<a
#rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>

View File

@ -2,27 +2,6 @@
:host {
color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
}
:host-context(.is-dark-theme) {

View File

@ -1,9 +1,17 @@
<div class="container">
<div class="row">
<div class="col text-center">
<h1 class="font-weight-bold intro mt-5" i18n>
Manage your wealth like a boss
</h1>
<div>
<div class="badge badge-light badge-pill border mb-3 px-3 py-2">
<a href="../en/blog/2023/09/ghostfolio-2"
><span class="mr-1 text-uppercase" i18n>New</span>
<span class="font-weight-normal">Ghostfolio 2.0</span></a
>
</div>
<h1 class="font-weight-bold intro" i18n>
Manage your wealth like a boss
</h1>
</div>
<p class="lead mb-4" i18n>
Ghostfolio is a privacy-first, open source dashboard for your personal
finances. Break down your asset allocation, know your net worth and make
@ -134,6 +142,14 @@
title="DEV Community - A constructive and inclusive social network for software developers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-hacker-news mask"
href="https://news.ycombinator.com"
target="_blank"
title="Hacker News"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-openstartup"

View File

@ -53,6 +53,10 @@
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openstartup {
background-image: url('/assets/images/logo-openstartup.png');
background-position: center;
@ -128,6 +132,7 @@
&.logo-agplv3,
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,

View File

@ -32,6 +32,7 @@
<div class="col-xs-12 col-md-4 my-2">
<gf-value
i18n
i18n-subLabel
size="large"
subLabel="(Last 24 hours)"
[locale]="user?.settings?.locale"
@ -42,6 +43,7 @@
<div class="col-xs-12 col-md-4 my-2">
<gf-value
i18n
i18n-subLabel
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
@ -52,6 +54,7 @@
<div class="col-xs-12 col-md-4 my-2">
<gf-value
i18n
i18n-subLabel
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
@ -119,6 +122,7 @@
<a class="d-block" href="https://status.ghostfol.io">
<gf-value
i18n
i18n-subLabel
size="large"
subLabel="(Last 90 days)"
[isPercent]="true"

View File

@ -24,7 +24,6 @@ import { ImportActivitiesDialog } from './import-activities-dialog/import-activi
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({
host: { class: 'page' },
selector: 'gf-activities-page',
styleUrls: ['./activities-page.scss'],
templateUrl: './activities-page.html'
@ -238,9 +237,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: {
activity,
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
accounts: this.user?.accounts,
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
@ -282,9 +279,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: {
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
accounts: this.user?.accounts,
activity: {
...aActivity,
accountId: aActivity?.accountId ?? this.defaultAccountId,

View File

@ -137,6 +137,20 @@ export class ImportActivitiesDialog implements OnDestroy {
}
}
public onFilesDropped({
files,
stepper
}: {
files: FileList;
stepper: MatStepper;
}): void {
if (files.length === 0) {
return;
}
this.handleFile({ stepper, file: files[0] });
}
public onImportStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === ImportStep.UPLOAD_FILE) {
this.importStep = ImportStep.UPLOAD_FILE;
@ -175,97 +189,15 @@ export class ImportActivitiesDialog implements OnDestroy {
aStepper.reset();
}
public onSelectFile(aStepper: MatStepper) {
public onSelectFile(stepper: MatStepper) {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
const { activities } =
await this.importActivitiesService.importJson({
accounts: content.accounts,
activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss();
aStepper.next();
this.changeDetectorRef.markForCheck();
}
};
this.handleFile({ file, stepper });
};
input.click();
@ -282,6 +214,97 @@ export class ImportActivitiesDialog implements OnDestroy {
this.unsubscribeSubject.complete();
}
private async handleFile({
file,
stepper
}: {
file: File;
stepper: MatStepper;
}): Promise<void> {
this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
const { activities } =
await this.importActivitiesService.importJson({
accounts: content.accounts,
activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss();
stepper.next();
this.changeDetectorRef.markForCheck();
}
};
}
private handleImportError({
activities,
error

View File

@ -70,29 +70,38 @@
<ng-template #selectFile>
<div class="d-flex flex-column justify-content-center">
<button
class="py-4"
color="primary"
mat-stroked-button
class="drop-area p-4 text-center text-muted"
gfFileDrop
(click)="onSelectFile(stepper)"
(filesDropped)="onFilesDropped({stepper, files: $event})"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
<div
class="align-items-center d-flex flex-column justify-content-center"
>
<ion-icon
class="cloud-icon"
name="cloud-upload-outline"
></ion-icon>
<span i18n>Choose or drop a file here</span>
</div>
</button>
<p class="mb-0 mt-4 text-center">
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
<p class="mb-0 mt-3 text-center">
<small>
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</small>
</p>
</div>
</ng-template>
@ -109,7 +118,7 @@
>
</ng-template>
<div class="pt-3">
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
<ng-container *ngIf="errorMessages?.length === 0; else errorMessage">
<gf-activities-table
*ngIf="importStep === 1"
[activities]="activities"

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatStepperModule } from '@angular/material/stepper';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
@ -23,6 +24,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfFileDropModule,
GfSymbolModule,
MatButtonModule,
MatDialogModule,

View File

@ -32,4 +32,32 @@
right: 1.5rem;
top: calc(50% - 10px);
}
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
}
.cloud-icon {
font-size: 2.5rem;
}
}
}
:host-context(.is-dark-theme) {
.drop-area {
border-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-alpha-dark)
);
}
}

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