Compare commits

...

106 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
2435535975 Release 1.302.0 (#2252) 2023-08-20 10:35:13 +02:00
bd3d43bf05 Feature/upgrade nx to version 16.7.2 (#2251)
* Upgrade Nx and Angular dependencies

* Update changelog
2023-08-20 10:32:52 +02:00
02dc7c52b1 Localize routes (#2250)
* Localize about path

* Localize faq path

* Localize features path

* Localize markets path

* Localize pricing path

* Localize register path

* Localize resources path

* Extend sitemap
2023-08-20 10:01:40 +02:00
ff59fd4196 Feature/improve language localization for german 20230819 (#2249)
* Improve language localization

* Update changelog
2023-08-19 19:49:40 +02:00
4955555ddd Release 1.301.1 (#2247) 2023-08-19 08:49:31 +02:00
a98c788a26 Release 1.301.0 (#2246) 2023-08-18 20:46:58 +02:00
9c16af81c7 Feature/setup oss friends page (#2245)
* Setup OSS Friends page

* Update changelog
2023-08-18 20:45:10 +02:00
2df27100f0 Add middleware (#2239)
* Add middleware

* Update changelog
2023-08-18 20:27:19 +02:00
6cf6538719 Feature/add currencies preset to historical market data table (#2243)
* Add currencies preset

* Update locales

* Update changelog
2023-08-18 19:33:00 +02:00
0fd3db3228 Bugfix/fix cash position rows in holdings table (#2237)
* Fix cash position rows

* Update changelog
2023-08-17 20:23:23 +02:00
18835149e2 Add repository (#2189) 2023-08-16 20:32:54 +02:00
6c9779fb0d Bugfix/change date creation from string using parse iso (#2236)
* Change date creation using parseISO

parseISO provides consistent date parsing across different time zones

* Update changelog
2023-08-15 19:24:31 +02:00
3e98f097ef Refactor account page to user account page (#2235)
* Refactor account page to user account page
2023-08-13 09:24:54 +02:00
183ac8fa2b Feature/add data export to user account page (#2234)
* Add data export

* Update changelog
2023-08-12 21:51:35 +02:00
9036f53e7d Reset benchmark in user settings (#2233) 2023-08-12 21:50:01 +02:00
f7c04e469a Release 1.300.0 (#2232) 2023-08-11 20:24:23 +02:00
b5f01c0d15 Feature/migrate requests from bent to got (#2231)
* Migrate requests from bent to got

* Update changelog
2023-08-11 20:20:35 +02:00
5a23cd34ad Replace variables (#2229) 2023-08-11 18:29:39 +02:00
6e87f34c6f Feature/add more durations in coupon system (#2228)
* Add 90 and 180 days

* Update changelog
2023-08-10 20:49:06 +02:00
6618aa2e9b Release 1.299.1 (#2227) 2023-08-10 07:58:34 +02:00
0d25a96f7e Release 1.299.0 (#2225) 2023-08-09 20:59:52 +02:00
4f6d9d3a76 Feature/add timeout to eod historical data requests (#2222)
* Add timeout to requests using got

* Update changelog
2023-08-09 20:58:00 +02:00
928f6f0c45 Bugfix/fix historical data gathering interval for benchmarks with activity (#2221)
* Fix historical data gathering interval for asset profiles used as benchmarks having activities

* Update changelog
2023-08-09 20:43:03 +02:00
09e95ddcee Bugfix/fix editing of emergency fund (#2220)
* Fix editing of emergency fund

* Update changelog
2023-08-09 19:41:42 +02:00
2d003225bc Allow custom currency in activity import (#2215)
* Allow custom currency in activity import

* Extend import test files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-08-08 20:00:55 +02:00
de93cabd69 Release 1.298.0 (#2214) 2023-08-06 09:13:04 +02:00
51489cca81 Feature/upgrade ng extract i18n merge to version 2.7.0 (#2155)
* Upgrade ng-extract-i18n-merge to version 2.7.0

* Update changelog
2023-08-06 09:10:14 +02:00
f7f4c3afb1 Feature/localize open startup page (#2213)
* Localize Open Startup page

* Update changelog
2023-08-06 09:09:21 +02:00
0821086e41 Bugfix/fix various styles after angular material 16 upgrade (#2212)
* Fix styles

* Update changelog
2023-08-06 08:52:45 +02:00
7a905fde63 Clean up (#2210) 2023-08-06 08:32:06 +02:00
d2882b1119 Clean up (#2206) 2023-08-06 08:31:48 +02:00
3a500598c5 Feature/upgrade nx to version 16.6.0 (#2211)
* Upgrade Nx to version 16.6.0

* Update changelog
2023-08-06 08:30:28 +02:00
42274917e0 Release 1.297.4 (#2209) 2023-08-05 19:57:39 +02:00
8ba50f2729 Release 1.297.3 (#2208) 2023-08-05 16:08:06 +02:00
f22071f061 Release 1.297.2 (#2207) 2023-08-05 14:51:10 +02:00
d2312371a6 Release 1.297.1 (#2205) 2023-08-05 14:35:13 +02:00
ba837c3c30 Release 1.297.0 (#2204) 2023-08-05 13:10:18 +02:00
d85d83a0f5 Feature/improve alignment of region percentages (#2203)
* Improve alignment

* Update changelog
2023-08-05 11:11:06 +02:00
62e8594c57 Feature/improve language localization for german 20230802 (#2200)
* Improve localization

* Update changelog
2023-08-05 11:10:15 +02:00
509f95ea30 Feature/add footer to public page (#2202)
* Add footer to public page

* Update changelog
2023-08-05 11:09:27 +02:00
43d0b55004 Feature/upgrade to angular 16 (#2156)
* Upgrade Angular, NestJS and Nx

* Replace executor to @nx/angular:webpack-browser and @nx/angular:webpack-dev-server

* Add target for copying assets

* Improve redirection of home page

* Update changelog
2023-08-05 11:08:10 +02:00
c0f130a077 Remove sitemap.xml (#2201) 2023-08-04 08:04:21 +02:00
90dc34380e Release 1.296.0 (#2199) 2023-08-01 09:12:26 +02:00
286e41eb21 Feature/optimize import validation by reducing to unique asset profiles (#2198)
* Optimize activities validation

* Optimize data gathering in import

* Update changelog
2023-08-01 09:10:13 +02:00
262 changed files with 37252 additions and 17709 deletions

View File

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

View File

@ -33,4 +33,4 @@ jobs:
run: yarn test run: yarn test
- name: Build application - name: Build application
run: yarn build:all run: yarn build:production

View File

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

View File

@ -5,6 +5,190 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.1.8` to `16.2.1`
- Upgraded `Nx` from version `16.6.0` to `16.7.2`
## 1.301.1 - 2023-08-19
### Added
- Added the data export feature to the user account page
- Added a currencies preset to the historical market data table of the admin control panel
- Added the _OSS Friends_ page
### Changed
- Improved the localized meta data in `html` files
### Fixed
- Fixed the rows with cash positions in the holdings table
- Fixed an issue with the date parsing in the historical market data editor of the admin control panel
## 1.300.0 - 2023-08-11
### Added
- Added more durations in the coupon system
### Changed
- Migrated the remaining requests from `bent` to `got`
## 1.299.1 - 2023-08-10
### Changed
- Optimized the activities import by allowing a different currency than the asset's official one
- Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
### Fixed
- Fixed the editing of the emergency fund
- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities
## 1.298.0 - 2023-08-06
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0`
- Upgraded `Nx` from version `16.5.5` to `16.6.0`
### Fixed
- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16`
## 1.297.4 - 2023-08-05
### Added
- Added the footer to the public page
- Added a `copy-assets` `Nx` target to the client build
### Changed
- Improved the alignment of the region percentages on the allocations page
- Improved the alignment of the region percentages on the public page
- Improved the redirection of the home page to the localized home page
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `15.2.5` to `16.1.8`
- Upgraded `nestjs` from version `9.1.4` to `10.1.3`
- Upgraded `Nx` from version `16.0.3` to `16.5.5`
## 1.296.0 - 2023-08-01
### Changed
- Optimized the validation in the activities import by reducing the list to unique asset profiles
- Optimized the data gathering in the activities import
## 1.295.0 - 2023-07-30 ## 1.295.0 - 2023-07-30
### Added ### Added
@ -552,7 +736,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the slide toggles to checkboxes on the account page - Changed the slide toggles to checkboxes on the user account page
- Changed the slide toggles to checkboxes in the admin control panel - Changed the slide toggles to checkboxes in the admin control panel
- Increased the density of the theme - Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc) - Migrated the style of various components to `@angular/material` `15` (mdc)
@ -1114,7 +1298,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the language selector on the account page - Improved the language selector on the user account page
- Improved the wording in the _X-ray_ section (net worth instead of investment) - Improved the wording in the _X-ray_ section (net worth instead of investment)
- Extended the asset profile details dialog in the admin control panel - Extended the asset profile details dialog in the admin control panel
- Updated the browserslist database - Updated the browserslist database
@ -1359,7 +1543,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up the language localization for Italiano (`it`) - Set up the language localization for Italian (`it`)
- Extended the landing page - Extended the landing page
## 1.195.0 - 20.09.2022 ## 1.195.0 - 20.09.2022
@ -1532,7 +1716,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a language selector to the account page - Added a language selector to the user account page
- Added support for translated labels in the value component - Added support for translated labels in the value component
### Changed ### Changed
@ -1861,7 +2045,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the user id to the account page - Added the user id to the user account page
- Added a new view with jobs of the queue to the admin control panel - Added a new view with jobs of the queue to the admin control panel
### Changed ### Changed
@ -2782,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 - Supported the management of additional currencies in the admin control panel
- Introduced the system message - Introduced the system message
- Introduced the read only mode - Introduced the read-only mode
### Changed ### Changed
@ -3516,7 +3700,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Respected the cash balance on the analysis page - Respected the cash balance on the analysis page
- Improved the settings selectors on the account page - Improved the settings selectors on the user account page
- Harmonized the slogan to "Open Source Wealth Management Software" - Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed ### Fixed
@ -3982,7 +4166,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a gradient to the line charts - Added a gradient to the line charts
- Added a selector to set the base currency on the account page - Added a selector to set the base currency on the user account page
## 0.81.0 - 06.04.2021 ## 0.81.0 - 06.04.2021
@ -4296,7 +4480,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Added the membership status to the account page - Added the membership status to the user account page
### Fixed ### Fixed

View File

@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps
RUN yarn build:all RUN yarn build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
@ -58,4 +58,4 @@ RUN apt update && apt install -y \
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ] CMD [ "yarn", "start:production" ]

View File

@ -13,6 +13,8 @@
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![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) [![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> </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. **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` 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. 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 ## Development
@ -153,7 +155,6 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `yarn build:dev` to build the source code including the assets
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema 1. Run `yarn database:setup` to initialize the database schema
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))

View File

@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"serve": { "serve": {
"executor": "@nx/node:node", "executor": "@nx/js:node",
"options": { "options": {
"buildTarget": "api:build" "buildTarget": "api:build"
} }

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -37,7 +38,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns'; import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@ -116,7 +117,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -152,7 +153,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -185,7 +186,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}); });
} }
@ -232,7 +233,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(
@ -332,7 +333,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' }, data: { marketPrice: data.marketPrice, state: 'CLOSE' },

View File

@ -7,13 +7,16 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_CURRENCY,
PROPERTY_CURRENCIES PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -25,8 +28,6 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -36,9 +37,7 @@ export class AdminService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async addAssetProfile({ public async addAssetProfile({
dataSource, dataSource,
@ -82,15 +81,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
label1: this.baseCurrency, label1: DEFAULT_CURRENCY,
label2: currency, label2: currency,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
this.baseCurrency, DEFAULT_CURRENCY,
currency currency
) )
}; };
@ -121,7 +120,9 @@ export class AdminService {
[{ symbol: 'asc' }]; [{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
if ( if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' || presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS' presetId === 'ETF_WITHOUT_SECTORS'
) { ) {
@ -306,13 +307,45 @@ export class AdminService {
response = await this.propertyService.delete({ key }); 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(); await this.exchangeRateDataService.initialize();
} }
return response; return response;
} }
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
let orderBy: any = { let orderBy: any = {
createdAt: 'desc' createdAt: 'desc'

View File

@ -7,11 +7,16 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
@ -23,7 +28,6 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
@ -32,6 +36,7 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -70,19 +75,30 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
serveStaticOptions: { exclude: ['/api*', '/sitemap.xml'],
/*etag: false // Disable etag header to fix PWA
setHeaders: (res, path) => {
if (path.includes('ngsw.json')) {
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}*/
},
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*'] serveStaticOptions: {
setHeaders: (res) => {
if (res.req?.path === '/') {
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = res.req.headers['accept-language']
.split(',')[0]
.split('-')[0];
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
}
}
}
}), }),
SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TwitterBotModule, TwitterBotModule,
@ -91,10 +107,4 @@ import { UserModule } from './user/user.module';
controllers: [AppController], controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule { export class AppModule {}
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateService } from './exchange-rate.service';
@ -23,7 +24,7 @@ export class ExchangeRateController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({ const exchangeRate = await this.exchangeRateService.getExchangeRate({
date, date,

View File

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

View File

@ -1,255 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = '';
public indexHtmlEn = '';
public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public sitemapXml = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
this.indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
this.indexHtmlEs = fs.readFileSync(
this.getPathOfIndexHtmlFile('es'),
'utf8'
);
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
);
this.indexHtmlNl = fs.readFileSync(
this.getPathOfIndexHtmlFile('nl'),
'utf8'
);
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
public use(request: Request, response: Response, next: NextFunction) {
const currentDate = format(new Date(), DATE_FORMAT);
let featureGraphicPath = 'assets/cover.png';
let title = 'Ghostfolio Open Source Wealth Management Software';
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
title = `500 Stars - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
title = `Hacktoberfest 2022 - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
title = `Black Friday 2022 - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
)
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
)
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
)
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
}
if (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!environment.production
) {
// Skip
next();
} else if (request.path === '/sitemap.xml') {
response.setHeader('content-type', 'application/xml');
response.send(
this.interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath,
title,
description:
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
languageCode: 'de',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
response.send(
this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
languageCode: 'es',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send(
this.interpolate(this.indexHtmlFr, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send(
this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
languageCode: 'it',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
response.send(
this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
languageCode: 'nl',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
response.send(
this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath,
title,
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename === '/sitemap.xml' ||
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -18,6 +18,19 @@ export class HealthController {
@Get() @Get()
public async getHealth() {} 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') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@ -30,9 +43,8 @@ export class HealthController {
); );
} }
const hasResponse = await this.healthService.hasResponseFromDataProvider( const hasResponse =
dataSource await this.healthService.hasResponseFromDataProvider(dataSource);
);
if (hasResponse !== true) { if (hasResponse !== true) {
throw new HttpException( throw new HttpException(

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule], imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
@Injectable() @Injectable()
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) {
return this.dataEnhancerService.enhance(aName);
}
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }

View File

@ -8,10 +8,15 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
@ -20,13 +25,15 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@ -220,8 +227,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}); });
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
@ -243,17 +249,47 @@ export class ImportService {
const activities: Activity[] = []; const activities: Activity[] = [];
for (const { for (let [
index,
{
accountId, accountId,
comment, comment,
date, date,
error, error,
fee, fee,
quantity, quantity,
SymbolProfile: assetProfile, SymbolProfile,
type, type,
unitPrice unitPrice
} of activitiesExtendedWithErrors) { }
] of activitiesExtendedWithErrors.entries()) {
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
@ -264,6 +300,35 @@ export class ImportService {
Account?: { id: string; name: string }; Account?: { id: string; name: string };
}); });
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!unitPrice) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
@ -279,23 +344,22 @@ export class ImportService {
id: uuidv4(), id: uuidv4(),
isDraft: isAfter(date, endOfToday()), isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass: assetProfile.assetClass, assetClass,
assetSubClass: assetProfile.assetSubClass, assetSubClass,
comment: assetProfile.comment, countries,
countries: assetProfile.countries, createdAt,
createdAt: assetProfile.createdAt, currency,
currency: assetProfile.currency, dataSource,
dataSource: assetProfile.dataSource, id,
id: assetProfile.id, isin,
isin: assetProfile.isin, name,
name: assetProfile.name, scraperConfiguration,
scraperConfiguration: assetProfile.scraperConfiguration, sectors,
sectors: assetProfile.sectors, symbol,
symbol: assetProfile.currency, symbolMapping,
symbolMapping: assetProfile.symbolMapping, updatedAt,
updatedAt: assetProfile.updatedAt, url,
url: assetProfile.url, comment: assetProfile.comment
...assetProfiles[assetProfile.symbol]
}, },
Account: validatedAccount, Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
@ -318,14 +382,14 @@ export class ImportService {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: assetProfile.currency, currency,
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
} }
} }
} }
@ -337,24 +401,49 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
assetProfile.currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
assetProfile.currency, currency,
userCurrency userCurrency
) )
}); });
} }
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}
return activities; return activities;
} }
@ -446,25 +535,30 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
userId: string;
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
} }
const assetProfiles: { const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol } { currency, dataSource, symbol }
] of activitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const assetProfile = ( const assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([
@ -472,19 +566,26 @@ export class ImportService {
]) ])
)?.[symbol]; )?.[symbol];
if (assetProfile === undefined) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (assetProfile.currency !== currency) { if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"` `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
); );
} }
assetProfiles[symbol] = assetProfile; assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
} }
} }

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; 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 { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import got from 'got';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
@ -41,15 +42,19 @@ export class LogoService {
} }
private getBuffer(aUrl: string) { private getBuffer(aUrl: string) {
const get = bent( 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`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
'GET',
'buffer',
200,
{ {
'User-Agent': 'request' headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
} }
); ).buffer();
return get();
} }
} }

View File

@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
export class OrderController { export class OrderController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -123,7 +125,7 @@ export class OrderController {
); );
} }
return this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -144,6 +146,19 @@ export class OrderController {
User: { connect: { id: this.request.user.id } }, User: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: order.date,
symbol: data.symbol
}
]);
}
return order;
} }
@Put(':id') @Put(':id')

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -117,7 +118,7 @@ export class OrderService {
}; };
} }
await this.dataGatheringService.addJobToQueue({ this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -125,26 +126,13 @@ export class OrderService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}` jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
} }
}); });
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
@ -162,6 +150,11 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,

View File

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

View File

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

View File

@ -10,7 +10,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
private baseCurrency: string;
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@ -57,9 +58,7 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -442,8 +441,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .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 { 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 { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
@ -90,11 +90,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private baseCurrency: string;
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -104,9 +101,7 @@ export class PortfolioService {
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts({ public async getAccounts({
filters, filters,
@ -470,9 +465,8 @@ export class PortfolioService {
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
); );
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,
@ -810,9 +804,8 @@ export class PortfolioService {
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const position = currentPositions.positions.find( const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
@ -1046,9 +1039,8 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1238,9 +1230,8 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1772,7 +1763,7 @@ export class PortfolioService {
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency; this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters, filters,
@ -1994,7 +1985,7 @@ export class PortfolioService {
return ( return (
aUser.Settings?.settings.baseCurrency ?? aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency DEFAULT_CURRENCY
); );
} }

View File

@ -1,8 +1,8 @@
import { Store } from 'cache-manager'; import { Store } from 'cache-manager';
import { RedisClient } from 'redis'; import { createClient } from 'redis';
export interface RedisStore extends Store { export interface RedisStore extends Store {
getClient: () => RedisClient; getClient: () => ReturnType<typeof createClient>;
isCacheableValue: (value: any) => boolean; isCacheableValue: (value: any) => boolean;
name: 'redis'; name: 'redis';
} }

View File

@ -1,7 +1,9 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common'; import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store'; import * as redisStore from 'cache-manager-redis-store';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service'; import { RedisCacheService } from './redis-cache.service';
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
imports: [ConfigurationModule], imports: [ConfigurationModule],
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
return <CacheManagerOptions>{ return <RedisClientOptions>{
host: configurationService.get('REDIS_HOST'), host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'), password: configurationService.get('REDIS_PASSWORD'),

View File

@ -1,6 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { RedisCache } from './interfaces/redis-cache.interface'; import type { RedisCache } from './interfaces/redis-cache.interface';
@ -22,7 +24,7 @@ export class RedisCacheService {
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }
public async remove(key: string) { public async remove(key: string) {
@ -34,8 +36,10 @@ export class RedisCacheService {
} }
public async set(key: string, value: string, ttlInSeconds?: number) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(key, value, { await this.cache.set(
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL') key,
}); value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
);
} }
} }

View File

@ -0,0 +1,36 @@
import * as fs from 'fs';
import * as path from 'path';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor() {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> {
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
}
}

View File

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
})
export class SitemapModule {}

View File

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

View File

@ -15,6 +15,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -93,7 +94,7 @@ export class SymbolController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(

View File

@ -4,7 +4,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
@ -15,24 +19,22 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService 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( public async getUser(
@ -145,8 +147,7 @@ export class UserService {
// Set default value for base currency // Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) { if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency = (user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
UserService.DEFAULT_CURRENCY;
} }
// Set default value for date range // Set default value for date range
@ -186,6 +187,14 @@ export class UserService {
if (Analytics?.activityCount % frequency === 1) { if (Analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial); currentPermissions.push(permissions.enableSubscriptionInterstitial);
} }
currentPermissions = without(
currentPermissions,
permissions.createAccess
);
// Reset benchmark
user.Settings.settings.benchmark = undefined;
} }
if (user.subscription?.type === 'Premium') { if (user.subscription?.type === 'Premium') {
@ -263,7 +272,7 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
@ -271,7 +280,7 @@ export class UserService {
Settings: { Settings: {
create: { create: {
settings: { settings: {
currency: this.baseCurrency currency: DEFAULT_CURRENCY
} }
} }
} }

View File

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

View File

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

View File

@ -50,6 +50,110 @@
<loc>https://ghostfol.io/de/ressourcen</loc> <loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </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> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -66,6 +170,10 @@
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc> <loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en</loc> <loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -82,6 +190,10 @@
<loc>https://ghostfol.io/en/about/license</loc> <loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/about/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/blog</loc> <loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -134,6 +246,14 @@
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc> <loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </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> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -314,6 +434,10 @@
<loc>https://ghostfol.io/es/sobre/licencia</loc> <loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc> <loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -334,6 +458,10 @@
<loc>https://ghostfol.io/fr/a-propos/licence</loc> <loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc> <loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -388,12 +516,16 @@
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc> <loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc> <loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc> <loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -418,6 +550,110 @@
<loc>https://ghostfol.io/it/risorse</loc> <loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </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> <url>
<loc>https://ghostfol.io/nl</loc> <loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -427,7 +663,111 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<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> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -452,6 +792,10 @@
<loc>https://ghostfol.io/nl/over/licentie</loc> <loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc> <loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -465,7 +809,11 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<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> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -512,6 +860,10 @@
<loc>https://ghostfol.io/pt/sobre/licenca</loc> <loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc> <loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -7,6 +7,7 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() { async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
@ -23,7 +24,7 @@ async function bootstrap() {
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api'); app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -40,6 +41,7 @@ async function bootstrap() {
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
@ -51,7 +53,8 @@ async function bootstrap() {
); );
} }
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY'); app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;
@ -59,15 +62,6 @@ async function bootstrap() {
logLogo(); logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`); Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log(''); 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

@ -0,0 +1,136 @@
import * as fs from 'fs';
import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const descriptions = {
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.'
};
const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
let indexHtmlMap: { [languageCode: string]: string } = {};
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch {}
const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
},
'/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}`
},
'/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}`
},
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}`
},
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}`
},
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1000 Stars on GitHub - ${titleShort}`
},
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
},
'/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}`
}
};
const isFileRequest = (filename: string) => {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
};
export const HtmlTemplateMiddleware = async (
request: Request,
response: Response,
next: NextFunction
) => {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
languageCode = DEFAULT_LANGUAGE_CODE;
}
const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL;
if (
path.startsWith('/api/') ||
isFileRequest(path) ||
!environment.production
) {
// Skip
next();
} else {
const indexHtml = interpolate(indexHtmlMap[languageCode], {
currentDate,
languageCode,
path,
rootUrl,
description: descriptions[languageCode],
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title
});
return response.send(indexHtml);
}
};

View File

@ -1,4 +1,5 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
@ -11,10 +12,6 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD'
}),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
@ -46,7 +43,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),

View File

@ -2,6 +2,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
@ -48,7 +49,7 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })

View File

@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
@ -28,6 +29,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringProcessor, DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],

View File

@ -4,14 +4,20 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import {
import { UniqueAsset } from '@ghostfolio/common/interfaces'; DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -30,6 +36,7 @@ export class DataGatheringService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -120,12 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles =
uniqueAssets await this.dataProviderService.getAssetProfiles(uniqueAssets);
); const symbolProfiles =
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
uniqueAssets
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -140,7 +145,9 @@ export class DataGatheringService {
}); });
} catch (error) { } catch (error) {
Logger.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, error,
'DataGatheringService' 'DataGatheringService'
); );
@ -221,7 +228,10 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}` jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-${format(date, DATE_FORMAT)}`
} }
}; };
}) })
@ -248,6 +258,10 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
@ -314,6 +328,14 @@ export class DataGatheringService {
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {};
(
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []
).forEach(({ symbolProfileId }) => {
benchmarkAssetProfileIdMap[symbolProfileId] = true;
});
const startDate = const startDate =
( (
await this.prismaService.order.findFirst({ await this.prismaService.order.findFirst({
@ -327,7 +349,7 @@ export class DataGatheringService {
return { return {
dataSource, dataSource,
symbol, symbol,
date: min([startDate, subYears(new Date(), 10)]) date: this.getEarliestDate(startDate)
}; };
}); });
@ -336,6 +358,7 @@ export class DataGatheringService {
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
dataSource: true, dataSource: true,
id: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -357,9 +380,15 @@ export class DataGatheringService {
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
let date = symbolProfile.Order?.[0]?.date ?? startDate;
if (benchmarkAssetProfileIdMap[symbolProfile.id]) {
date = this.getEarliestDate(startDate);
}
return { return {
...symbolProfile, ...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate date
}; };
}); });

View File

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

View File

@ -1,10 +1,13 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; 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 { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -15,19 +18,14 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3'; private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor( public constructor() {}
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -39,14 +37,22 @@ export class CoinGeckoService implements DataProviderInterface {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName(), dataSource: this.getName(),
symbol: aSymbol symbol: aSymbol
}; };
try { try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200); const abortController = new AbortController();
const { name } = await get();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -79,17 +85,23 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { prices } = await got(
`${ `${
this.URL 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 from
)}&to=${getUnixTime(to)}`, )}&to=${getUnixTime(to)}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const { prices } = await get(); ).json<any>();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -132,23 +144,29 @@ export class CoinGeckoService implements DataProviderInterface {
} }
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${aSymbols.join(
',' ','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
for (const symbol in response) { for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO, dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()], marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open' marketState: 'open'
}; };
} }
@ -174,8 +192,16 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200); const abortController = new AbortController();
const { coins } = await get();
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 }) => { items = coins.map(({ id: symbol, name }) => {
return { return {
@ -183,7 +209,7 @@ export class CoinGeckoService implements DataProviderInterface {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName() 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 { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataEnhancerService } from './data-enhancer.service';
@Module({ @Module({
exports: [ exports: [
'DataEnhancers', DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService,
'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule],
providers: [ providers: [
DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, 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,15 +1,14 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; 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 { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import bent from 'bent'; import got from 'got';
const getJSON = bent('json');
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { 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 countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
@ -34,26 +33,82 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const profile = await getJSON( let abortController = new AbortController();
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
).catch(() => { setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
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 {}; return {};
}); });
});
const isin = profile.isin?.split(';')?.[0]; const isin = profile?.isin?.split(';')?.[0];
if (isin) { if (isin) {
response.isin = isin; response.isin = isin;
} }
const holdings = await getJSON( abortController = new AbortController();
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => { setTimeout(() => {
return getJSON( abortController.abort();
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${ }, DEFAULT_REQUEST_TIMEOUT);
symbol.split('.')?.[0]
}.json` const holdings = await got(
); `${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`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
});
}); });
if (holdings?.weight < 0.95) { if (holdings?.weight < 0.95) {
@ -112,4 +167,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getName() { public getName() {
return 'TRACKINSIGHT'; 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 { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService 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 { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; 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 { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
@ -16,23 +16,18 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace( let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
); );
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) { if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) {
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol.replace('=X', ''); return symbol.replace('=X', '');
@ -47,21 +42,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if ( if (
aSymbol.includes(this.baseCurrency) && aSymbol.includes(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace( aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY)
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
) )
) { ) {
// Add a dash before the last three characters // Add a dash before the last three characters
@ -69,8 +61,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace( return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`), new RegExp(`-?${DEFAULT_CURRENCY}$`),
`-${this.baseCurrency}` `-${DEFAULT_CURRENCY}`
); );
} }
} }
@ -99,15 +91,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
yahooSymbol = quotes[0].symbol; yahooSymbol = quotes[0].symbol;
} }
const { countries, sectors, url } = await this.getAssetProfile( const { countries, sectors, url } =
yahooSymbol await this.getAssetProfile(yahooSymbol);
);
if (countries) { if ((countries as unknown as Prisma.JsonArray)?.length > 0) {
response.countries = countries; response.countries = countries;
} }
if (sectors) { if ((sectors as unknown as Prisma.JsonArray)?.length > 0) {
response.sectors = sectors; response.sectors = sectors;
} }
@ -234,6 +225,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public getTestSymbol() {
return 'AAPL';
}
public parseAssetClass({ public parseAssetClass({
quoteType, quoteType,
shortName shortName

View File

@ -5,6 +5,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -14,21 +18,19 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -76,19 +78,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
const symbol = this.convertToEodSymbol(aSymbol); const symbol = this.convertToEodSymbol(aSymbol);
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
this.apiKey this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}&period={aGranularity}`, )}&period={aGranularity}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
).json<any>();
const response = await get();
return response.reduce( return response.reduce(
(result, historicalItem, index, array) => { (result, historicalItem, index, array) => {
@ -136,16 +143,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${symbols.join(',')}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
).json<any>();
const realTimeResponse = await get();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
@ -174,7 +186,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency; })?.currency;
result[this.convertFromEodSymbol(code)] = { result[this.convertFromEodSymbol(code)] = {
currency: currency ?? this.baseCurrency, currency: currency ?? DEFAULT_CURRENCY,
dataSource: DataSource.EOD_HISTORICAL_DATA, dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -185,24 +197,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
{} {}
); );
if (response[`${this.baseCurrency}GBP`]) { if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${this.baseCurrency}GBP`], ...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${this.baseCurrency}GBp`, currency: `${DEFAULT_CURRENCY}GBp`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${this.baseCurrency}GBP`].marketPrice value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
}) })
}; };
} }
if (response[`${this.baseCurrency}ILS`]) { if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${this.baseCurrency}ILS`], ...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${this.baseCurrency}ILA`, currency: `${DEFAULT_CURRENCY}ILA`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${this.baseCurrency}ILS`].marketPrice value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
}) })
}; };
} }
@ -271,7 +283,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) { if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol; return symbol;
@ -284,17 +296,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (
aSymbol.startsWith(this.baseCurrency) && aSymbol.startsWith(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol return `${aSymbol
.replace('GBp', 'GBX') .replace('GBp', 'GBX')
.replace(this.baseCurrency, '')}.FOREX`; .replace(DEFAULT_CURRENCY, '')}.FOREX`;
} }
} }
@ -308,10 +320,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} }
@ -329,13 +341,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
let searchResult = []; let searchResult = [];
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
searchResult = response.map( searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {

View File

@ -5,18 +5,21 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } 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 { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format, isAfter, isBefore, isSameDay } from 'date-fns'; import { format, isAfter, isBefore, isSameDay } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3'; private readonly URL = 'https://financialmodelingprep.com/api/v3';
public constructor( public constructor(
@ -25,7 +28,6 @@ export class FinancialModelingPrepService implements DataProviderInterface {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'FINANCIAL_MODELING_PREP_API_KEY' 'FINANCIAL_MODELING_PREP_API_KEY'
); );
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -64,13 +66,19 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( 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}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const { historical } = await get(); ).json<any>();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -115,17 +123,23 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
try { try {
const get = bent( 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}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of response) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price,
@ -153,13 +167,19 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent( 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}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const result = await get(); ).json<any>();
items = result.map(({ currency, name, symbol }) => { items = result.map(({ currency, name, symbol }) => {
return { return {

View File

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

View File

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
extractNumberFromString, extractNumberFromString,
@ -14,10 +15,10 @@ import {
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -95,10 +96,19 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const get = bent(url, 'GET', 'string', 200, headers); const abortController = new AbortController();
const html = await get(); setTimeout(() => {
const $ = cheerio.load(html); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
const value = extractNumberFromString($(selector).text()); const value = extractNumberFromString($(selector).text());

View File

@ -5,13 +5,16 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } 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 { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns'; import { format } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
@ -135,19 +138,25 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
'GET',
'json',
200,
{ {
useQueryString: true, headers: {
useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
},
// @ts-ignore
signal: abortController.signal
} }
); ).json<any>();
const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); Logger.error(error, 'RapidApiService');

View File

@ -1,5 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; 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 { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.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'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -7,6 +6,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -18,15 +18,10 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -212,50 +207,50 @@ export class YahooFinanceService implements DataProviderInterface {
}; };
if ( if (
symbol === `${this.baseCurrency}GBP` && symbol === `${DEFAULT_CURRENCY}GBP` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`)
) { ) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[symbol], ...response[symbol],
currency: 'GBp', currency: 'GBp',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ILS` && symbol === `${DEFAULT_CURRENCY}ILS` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`)
) { ) {
// Convert ILS to ILA // Convert ILS to ILA
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[symbol], ...response[symbol],
currency: 'ILA', currency: 'ILA',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ZAR` && symbol === `${DEFAULT_CURRENCY}ZAR` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`)
) { ) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
response[`${this.baseCurrency}ZAc`] = { response[`${DEFAULT_CURRENCY}ZAc`] = {
...response[symbol], ...response[symbol],
currency: 'ZAc', currency: 'ZAc',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ZAc`, symbol: `${DEFAULT_CURRENCY}ZAc`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} }
} }
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) { if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) {
// Convert USD to USX (cent) // Convert USD to USX (cent)
response[`${this.baseCurrency}USX`] = { response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX', currency: 'USX',
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(), marketPrice: new Big(1).mul(100).toNumber(),
@ -303,8 +298,8 @@ export class YahooFinanceService implements DataProviderInterface {
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace( symbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
) )
)) || )) ||
quoteTypes.includes(quoteType) quoteTypes.includes(quoteType)
@ -314,7 +309,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // 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') { } else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F // Allow GC=F, but not MGC=F
return symbol.length === 4; return symbol.length === 4;
@ -373,13 +368,13 @@ export class YahooFinanceService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) { } else if (symbol === `${DEFAULT_CURRENCY}ZAc`) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber(); 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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 { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
@ -12,13 +14,11 @@ import { isNumber, uniq } from 'lodash';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -26,15 +26,23 @@ export class ExchangeRateDataService {
) {} ) {}
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY];
} }
public getCurrencyPairs() { public getCurrencyPairs() {
return this.currencyPairs; return this.currencyPairs;
} }
public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => {
return (
symbol === `${currency1}${currency2}` ||
symbol === `${currency2}${currency1}`
);
});
}
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
@ -104,9 +112,9 @@ export class ExchangeRateDataService {
if (!this.exchangeRates[symbol]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via base currency // Not found, calculate indirectly via base currency
this.exchangeRates[symbol] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${this.baseCurrency}`]?.[date] resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date]
?.marketPrice * ?.marketPrice *
resultExtended[`${this.baseCurrency}${currency2}`]?.[date] resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date]
?.marketPrice; ?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
@ -135,9 +143,8 @@ export class ExchangeRateDataService {
} else { } else {
// Calculate indirectly via base currency // Calculate indirectly via base currency
const factor1 = const factor1 =
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`]; this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`];
const factor2 = const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`];
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
@ -195,28 +202,28 @@ export class ExchangeRateDataService {
let marketPriceBaseCurrencyToCurrency: number; let marketPriceBaseCurrencyToCurrency: number;
try { try {
if (this.baseCurrency === aFromCurrency) { if (aFromCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyFromCurrency = 1; marketPriceBaseCurrencyFromCurrency = 1;
} else { } else {
marketPriceBaseCurrencyFromCurrency = ( marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}` symbol: `${DEFAULT_CURRENCY}${aFromCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
} catch {} } catch {}
try { try {
if (this.baseCurrency === aToCurrency) { if (aToCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyToCurrency = 1; marketPriceBaseCurrencyToCurrency = 1;
} else { } else {
marketPriceBaseCurrencyToCurrency = ( marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}` symbol: `${DEFAULT_CURRENCY}${aToCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
@ -286,14 +293,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies return aCurrencies
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
currency1: this.baseCurrency, currency1: DEFAULT_CURRENCY,
currency2: currency, currency2: currency,
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), 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 { export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string; BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;

View File

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

View File

@ -4,7 +4,7 @@
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["node"], "types": ["node"],
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2021"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]

View File

@ -11,65 +11,15 @@
"prefix": "gf", "prefix": "gf",
"targets": { "targets": {
"build": { "build": {
"executor": "@angular-devkit/build-angular:browser", "executor": "@nx/angular:webpack-browser",
"options": { "options": {
"localize": true,
"outputPath": "dist/apps/client", "outputPath": "dist/apps/client",
"index": "apps/client/src/index.html", "index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [ "assets": [],
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "favicon.ico",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "site.webmanifest",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": [ "styles": [
"apps/client/src/styles/theme.scss", "apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss" "apps/client/src/styles.scss"
@ -144,8 +94,51 @@
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"],
"defaultConfiguration": "" "defaultConfiguration": ""
}, },
"copy-assets": {
"executor": "nx:run-commands",
"options": {
"commands": [
{
"command": "mkdir -p dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
},
{
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
},
{
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
},
{
"command": "cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "cp LICENSE dist/apps/client/assets"
}
]
}
},
"serve": { "serve": {
"executor": "@angular-devkit/build-angular:dev-server", "executor": "@nx/angular:webpack-dev-server",
"options": { "options": {
"browserTarget": "client:build", "browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json" "proxyConfig": "apps/client/proxy.conf.json"

View File

@ -4,25 +4,29 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
export const paths = {
about: $localize`about`,
faq: $localize`faq`,
features: $localize`features`,
license: $localize`license`,
markets: $localize`markets`,
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
};
const routes: Routes = [ const routes: Routes = [
...[ {
'about', path: paths.about,
/////
'a-propos',
'informazioni-su',
'over',
'sobre',
'ueber-uns'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})), },
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
import('./pages/account/account-page.module').then( import('./pages/user-account/user-account-page.module').then(
(m) => m.AccountPageModule (m) => m.UserAccountPageModule
) )
}, },
{ {
@ -42,64 +46,40 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
...['blog'].map((path) => ({ {
path, path: 'blog',
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})), },
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
}, },
...[ {
'faq', path: paths.faq,
/////
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
})), },
...[ {
'features', path: paths.features,
/////
'fonctionnalites',
'funcionalidades',
'funzionalita',
'kenmerken'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/features/features-page.module').then( import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule (m) => m.FeaturesPageModule
) )
})), },
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
...[ {
'markets', path: paths.markets,
/////
'maerkte',
'marches',
'markten',
'mercados',
'mercati'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/markets/markets-page.module').then( import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule (m) => m.MarketsPageModule
) )
})), },
{ {
path: 'open', path: 'open',
loadChildren: () => loadChildren: () =>
@ -119,53 +99,27 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
...[ {
'pricing', path: paths.pricing,
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
'prix'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule (m) => m.PricingPageModule
) )
})), },
...[ {
'register', path: paths.register,
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule (m) => m.RegisterPageModule
) )
})), },
...[ {
'resources', path: paths.resources,
/////
'bronnen',
'recursos',
'ressourcen',
'ressources',
'risorse'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/resources/resources-page.module').then( import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule (m) => m.ResourcesPageModule
) )
})), },
{ {
path: 'start', path: 'start',
loadChildren: () => loadChildren: () =>

View File

@ -1,37 +1,26 @@
<header> <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 <div
*ngIf="canCreateAccount || (info?.systemMessage && user)" *ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container" class="info-message-container"
> >
<div class="row"> <div class="info-message-inner-container position-fixed w-100">
<div class="col-md-8 offset-md-2 text-center"> <div class="align-items-center d-flex h-100 justify-content-center">
<a <a
*ngIf="canCreateAccount" *ngIf="canCreateAccount"
class="text-center" class="text-center"
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
> >
<div <div
class="cursor-pointer d-inline-block info-message px-3 py-2" class="cursor-pointer d-inline-block info-message"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<span class="a ml-2">Create Account</span> <span class="a ml-2" i18n>Create Account</span>
</div></a </div></a
> >
<div <div
*ngIf="!canCreateAccount && info?.systemMessage && user" *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()" (click)="onShowSystemMessage()"
> >
{{ info.systemMessage }} {{ info.systemMessage }}
@ -40,24 +29,22 @@
</div> </div>
</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> <router-outlet></router-outlet>
</main> </main>
<footer <footer *ngIf="showFooter" class="d-flex justify-content-center py-4 w-100">
*ngIf="
(currentRoute === 'blog' ||
currentRoute === 'faq' ||
currentRoute === 'features' ||
currentRoute === 'markets' ||
currentRoute === 'open' ||
currentRoute === 'pricing' ||
currentRoute === 'resources' ||
currentRoute === 'register' ||
currentRoute === 'start') &&
deviceType !== 'mobile'
"
class="d-flex justify-content-center py-4 w-100"
>
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-sm"> <div class="col-sm">
@ -67,36 +54,38 @@
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex"> <li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a> <a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li> </li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li> <li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul> </ul>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="['/about']">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForBlog"> <li *ngIf="hasPermissionForBlog">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a> <a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li> </li>
<li><a i18n [routerLink]="['/features']">Features</a></li> <li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a> <a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'license']">License</a> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li> </li>
<li *ngIf="hasPermissionForStatistics"> <li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/pricing']">Pricing</a> <a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/about', 'privacy-policy']" <a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a >Privacy Policy</a
> >
</li> </li>

View File

@ -4,31 +4,47 @@
display: block; display: block;
min-height: 100vh; 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 { footer {
background-color: rgba(var(--palette-foreground-text), 0.05); background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%; font-size: 90%;
} }
header {
height: var(--mat-toolbar-standard-height);
}
main { main {
min-height: 100vh; min-height: calc(100vh - var(--mat-toolbar-standard-height));
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;
}
}
}
} }
} }
@ -36,12 +52,4 @@
footer { footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05); 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, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
HostBinding,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit
@ -28,16 +29,36 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnDestroy, OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`about`,
$localize`privacy-policy`
];
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
@ -89,6 +110,27 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; 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) ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' ||
this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'start') &&
this.deviceType !== 'mobile';
if (this.deviceType === 'mobile') { if (this.deviceType === 'mobile') {
setTimeout(() => { setTimeout(() => {
const index = this.title.getTitle().indexOf(''); const index = this.title.getTitle().indexOf('');
@ -113,6 +155,12 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.createUserAccount permissions.createUserAccount
); );
this.hasInfoMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck(); 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"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <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 { export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean; @Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>(); @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 { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AccessTableComponent } from './access-table.component'; import { AccessTableComponent } from './access-table.component';
@NgModule({ @NgModule({
declarations: [AccessTableComponent], declarations: [AccessTableComponent],
exports: [AccessTableComponent], exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule], imports: [
CommonModule,
MatButtonModule,
MatMenuModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioAccessTableModule {} export class GfPortfolioAccessTableModule {}

View File

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

View File

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

View File

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

View File

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

View File

@ -154,7 +154,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string; day: string;
yearMonth: string; yearMonth: string;
}) { }) {
const date = new Date(`${yearMonth}-${day}`); const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) { if (isSameDay(date, new Date())) {

View File

@ -60,6 +60,11 @@ export class AdminMarketDataComponent
}; };
}) })
.concat([ .concat([
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: <Filter['type']>'PRESET_ID'
},
{ {
id: 'ETF_WITHOUT_COUNTRIES', id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`, label: $localize`ETFs without Countries`,

View File

@ -169,6 +169,8 @@
<mat-option value="7 days">7 Days</mat-option> <mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option> <mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option> <mat-option value="30 days">30 Days</mat-option>
<mat-option value="90 days">90 Days</mat-option>
<mat-option value="180 days">180 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option> <mat-option value="1 year">1 Year</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

View File

@ -1,14 +1,14 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Platforms</h3> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform> <gf-admin-platform></gf-admin-platform>
</div> </div>
</div> </div>
<!-- <!--
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Tags</h3> <h2 class="text-center" i18n>Tags</h2>
</div> </div>
</div> </div>
--> -->

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-admin-settings', selector: 'gf-admin-settings',
styleUrls: ['./admin-settings.component.scss'], styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html' 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"> <ng-container *ngIf="user">
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a <a
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0" class="align-items-center justify-content-start rounded-0"
mat-button mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']" [routerLink]="['/']"
> >
<gf-logo [label]="pageTitle"></gf-logo> <gf-logo class="px-2" [label]="pageTitle"></gf-logo>
</a> </a>
</div>
<span class="spacer"></span> <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"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -26,7 +29,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -39,7 +42,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -52,7 +55,7 @@
</li> </li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item"> <li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -65,14 +68,14 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources', 'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === 'resources' 'text-decoration-underline': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
</li> </li>
@ -83,27 +86,27 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
@ -129,6 +132,7 @@
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0"> <ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex">
<ion-icon <ion-icon
*ngIf="user?.access?.length > 0" *ngIf="user?.access?.length > 0"
class="mr-2" class="mr-2"
@ -139,12 +143,14 @@
" "
></ion-icon> ></ion-icon>
<span i18n>Me</span> <span i18n>Me</span>
</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
<span class="align-items-center d-flex">
<ion-icon <ion-icon
class="mr-2" class="mr-2"
name="square-outline" name="square-outline"
@ -156,6 +162,7 @@
></ion-icon> ></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> <span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span> <span *ngIf="!accessItem.alias" i18n>User</span>
</span>
</button> </button>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
@ -210,9 +217,9 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources' 'font-weight-bold': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
<a <a
@ -223,16 +230,16 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }" [ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }" [ngClass]="{ 'font-weight-bold': currentRoute === routeAbout }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About Ghostfolio</a >About Ghostfolio</a
> >
<hr class="d-flex d-sm-none m-0" /> <hr class="d-flex d-sm-none m-0" />
@ -242,53 +249,58 @@
</ul> </ul>
</ng-container> </ng-container>
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a <a
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0" class="align-items-center justify-content-start rounded-0"
mat-button mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']" [routerLink]="['/']"
> >
<gf-logo <gf-logo
class="px-2"
[label]="pageTitle" [label]="pageTitle"
[showLabel]="currentRoute !== 'register'" [showLabel]="currentRoute !== 'register'"
></gf-logo> ></gf-logo>
</a> </a>
</div>
<span class="spacer"></span> <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"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'features', 'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === 'features' 'text-decoration-underline': currentRoute === routeFeatuers
}" }"
[routerLink]="['/features']" [routerLink]="routerLinkFeatures"
>Features</a >Features</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item"> <li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a <a
class="d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
@ -297,14 +309,14 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'markets', 'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === 'markets' 'text-decoration-underline': currentRoute === routeMarkets
}" }"
[routerLink]="['/markets']" [routerLink]="routerLinkMarkets"
>Markets</a >Markets</a
> >
</li> </li>
@ -317,19 +329,19 @@
></a> ></a>
</li> </li>
<li class="list-inline-item"> <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> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
<li <li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item" class="list-inline-item ml-1"
> >
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
><ng-container i18n>Get started</ng-container> ><ng-container i18n>Get started</ng-container>
</a> </a>
</li> </li>

View File

@ -7,8 +7,18 @@
.mat-toolbar { .mat-toolbar {
background-color: var(--light-background); background-color: var(--light-background);
.spacer { .logo-container {
flex: 1 1 auto; &.filled {
background-color: rgba(var(--palette-foreground-base), 0.02);
}
@media (min-width: 576px) {
width: 14rem;
}
}
.list-inline-item {
margin: 0;
} }
.mdc-button { .mdc-button {
@ -24,11 +34,21 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
.spacer {
flex: 1 1 auto;
}
} }
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-toolbar { .mat-toolbar {
background-color: var(--dark-background); 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 { export class HeaderComponent implements OnChanges {
@Input() currentRoute: string; @Input() currentRoute: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@Input() user: User; @Input() user: User;
@ -42,6 +43,17 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public impersonationId: string; public impersonationId: string;
public isMenuOpen: boolean; public isMenuOpen: boolean;
public routeAbout = $localize`about`;
public routeFeatures = $localize`features`;
public routeMarkets = $localize`markets`;
public routePricing = $localize`pricing`;
public routeResources = $localize`resources`;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

View File

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted"> <div class="mb-2 text-center text-muted">

View File

@ -101,7 +101,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioDetails({}) .fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.summary = summary; this.summary = summary;
@ -121,7 +121,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/pricing']); this.router.navigate(['/' + $localize`pricing`]);
}); });
} }

View File

@ -1,5 +1,5 @@
<div class="container pb-3 px-3"> <div class="container pb-3 px-3">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Summary</h3> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Summary</h1>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<mat-card appearance="outlined"> <mat-card appearance="outlined">

View File

@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() { public onEditEmergencyFund() {
const emergencyFundInput = prompt( const emergencyFundInput = prompt(
$localize`Please enter the amount of your emergency fund:`, $localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund?.toString() ?? '0' this.summary.emergencyFund?.total?.toString() ?? '0'
); );
const emergencyFund = parseFloat(emergencyFundInput?.trim()); const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -11,6 +11,8 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
templateUrl: 'subscription-interstitial-dialog.html' templateUrl: 'subscription-interstitial-dialog.html'
}) })
export class SubscriptionInterstitialDialog { export class SubscriptionInterstitialDialog {
public routerLinkPricing = ['/' + $localize`pricing`];
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams, @Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog> public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>

View File

@ -56,7 +56,7 @@
<a <a
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
(click)="closeDialog()" (click)="closeDialog()"
> >
<span i18n>Upgrade Plan</span> <span i18n>Upgrade Plan</span>

View File

@ -1,10 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
CanActivate,
Router, Router,
RouterStateSnapshot RouterStateSnapshot
} from '@angular/router'; } from '@angular/router';
import { paths } from '@ghostfolio/client/app-routing.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -12,23 +12,19 @@ import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate { export class AuthGuard {
private static PUBLIC_PAGE_ROUTES = [ private static PUBLIC_PAGE_ROUTES = [
'/about', `/${paths.about}`,
'/about/changelog',
'/about/privacy-policy',
'/blog', '/blog',
'/de/blog',
'/demo', '/demo',
'/en/blog', `/${paths.faq}`,
'/faq', `/${paths.features}`,
'/features', `/${paths.markets}`,
'/markets',
'/open', '/open',
'/p', '/p',
'/pricing', `/${paths.pricing}`,
'/register', `/${paths.register}`,
'/resources' `/${paths.resources}`
]; ];
constructor( constructor(
@ -54,7 +50,7 @@ export class AuthGuard implements CanActivate {
this.router.navigate(['/demo']); this.router.navigate(['/demo']);
resolve(false); resolve(false);
} else if (utmSource === 'trusted-web-activity') { } else if (utmSource === 'trusted-web-activity') {
this.router.navigate(['/register']); this.router.navigate(['/' + $localize`register`]);
resolve(false); resolve(false);
} else if ( } else if (
AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) =>

View File

@ -77,7 +77,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/pricing']); this.router.navigate(['/' + $localize`pricing`]);
}); });
} }
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) { } else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {

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,5 +1,8 @@
import * as path from 'path';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { paths } from '@ghostfolio/client/app-routing.module';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -22,38 +25,27 @@ const routes: Routes = [
(m) => m.ChangelogPageModule (m) => m.ChangelogPageModule
) )
}, },
...[ {
'license', path: paths.license,
/////
'licenca',
'licence',
'licencia',
'licentie',
'lizenz',
'licenza'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./license/license-page.module').then( import('./license/license-page.module').then(
(m) => m.LicensePageModule (m) => m.LicensePageModule
) )
})), },
...[ {
'privacy-policy', path: 'oss-friends',
///// loadChildren: () =>
'datenschutzbestimmungen', import('./oss-friends/oss-friends-page.module').then(
'informativa-sulla-privacy', (m) => m.OpenSourceSoftwareFriendsPageModule
'politique-de-confidentialite', )
'politica-de-privacidad', },
'politica-de-privacidade', {
'privacybeleid' path: paths.privacyPolicy,
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then( import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule (m) => m.PrivacyPolicyPageModule
) )
})) }
], ],
component: AboutPageComponent, component: AboutPageComponent,
path: '', path: '',

View File

@ -1,28 +1,20 @@
import { import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' },
selector: 'gf-about-page', selector: 'gf-about-page',
styleUrls: ['./about-page.scss'], styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() { public deviceType: string;
return this.hasMessage;
}
public hasMessage: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
@ -32,9 +24,10 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions, systemMessage } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
globalPermissions, globalPermissions,
@ -44,44 +37,47 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) {
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
label: $localize`About`, label: $localize`About`,
path: ['/about'] path: ['/' + $localize`about`]
}, },
{ {
iconName: 'sparkles-outline', iconName: 'sparkles-outline',
label: $localize`Changelog`, label: $localize`Changelog`,
path: ['/about', 'changelog'] path: ['/' + $localize`about`, 'changelog']
}, },
{ {
iconName: 'ribbon-outline', iconName: 'ribbon-outline',
label: $localize`License`, label: $localize`License`,
path: ['/about', 'license'] path: ['/' + $localize`about`, $localize`license`]
},
{
iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`,
path: ['/about', 'privacy-policy'],
showCondition: this.hasPermissionForSubscription
} }
]; ];
this.user = state.user;
this.hasMessage = if (state?.user) {
hasPermission( this.tabs.push({
this.user?.permissions, iconName: 'shield-checkmark-outline',
permissions.createUserAccount label: $localize`Privacy Policy`,
) || !!systemMessage; path: ['/' + $localize`about`, $localize`privacy-policy`],
showCondition: this.hasPermissionForSubscription
});
this.user = state.user;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
this.tabs.push({
iconName: 'happy-outline',
label: 'OSS Friends',
path: ['/' + $localize`about`, 'oss-friends']
});
}); });
} }
public ngOnInit() {} public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </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"> <ng-container *ngFor="let tab of tabs">
<a <a
#rla="routerLinkActive" #rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path" [routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }" [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> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>
</ng-container> </ng-container>

View File

@ -2,32 +2,6 @@
:host { :host {
color: rgb(var(--dark-primary-text)); 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 {
gf-about-page,
gf-changelog-page,
gf-privacy-policy-page {
flex: 1 1 auto;
overflow-y: auto;
}
.mat-mdc-tab-link-container {
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

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

View File

@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Changelog</h1>
<div class="changelog"> <div class="changelog">
<markdown [src]="'../assets/CHANGELOG.md'"></markdown> <markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</div> </div>

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