diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd17aead..f4c28f319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the language localization for German (`de`) + +## 1.181.2 - 21.08.2022 + +### Added + +- Added a language selector to the account page +- Added support for translated labels in the value component + +### Changed + +- Integrated the commands `database:setup` and `database:migrate` into the container start + +### Fixed + +- Fixed a division by zero error in the benchmarks calculation + +### Todo + +- Apply manual data migration (`yarn database:migrate`) is not needed anymore + +## 1.180.1 - 18.08.2022 + +### Added + +- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow +- Set up language localization for German (`de`) +- Resolved the feature graphic of the blog post + +### Changed + +- Tagged template literal strings in components for localization with `$localize` + +### Fixed + +- Fixed the license component in the about page +- Fixed the links to the blog posts + +## 1.179.5 - 15.08.2022 + +### Added + +- Set up i18n support +- Added a blog post: _500 Stars on GitHub_ + +### Changed + +- Reduced the maximum width of the performance chart on the home page + +## 1.178.0 - 09.08.2022 + +### Added + +- Added `url` to the symbol profile overrides model for manual adjustments +- Added default values for `countries` and `sectors` of the symbol profile overrides model + +### Changed + - Simplified the initialization of the exchange rate service +- Improved the orders query for `assetClass` with symbol profile overrides +- Improved the styling of the benchmarks in the markets overview + +### Todo + +- Apply data migration (`yarn database:migrate`) ## 1.177.0 - 04.08.2022 diff --git a/Dockerfile b/Dockerfile index 9a965ddf0..a72236805 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,4 +58,4 @@ RUN apt update && apt install -y \ COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps WORKDIR /ghostfolio/apps/api EXPOSE 3333 -CMD [ "node", "main" ] +CMD [ "yarn", "start:prod" ] diff --git a/README.md b/README.md index 712776bb9..2ae2808cf 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h docker-compose --env-file ./.env -f docker/docker-compose.yml up -d ``` -##### Setup Database - -Run the following command to setup the database once Ghostfolio is running: - -```bash -docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup -``` - #### b. Build and run environment Run the following commands to build and start the Docker images: @@ -131,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d ``` -##### Setup Database - -Run the following command to setup the database once Ghostfolio is running: - -```bash -docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup -``` - #### Fetch Historical Data Open http://localhost:3333 in your browser and accomplish these steps: @@ -150,8 +134,8 @@ Open http://localhost:3333 in your browser and accomplish these steps: #### Upgrade Version 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` -1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` -1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` +1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` + At each start, the container will automatically apply the database schema migrations if needed. ### Run with _Unraid_ (Community) diff --git a/angular.json b/angular.json index bd60b92ee..8f6f6e3de 100644 --- a/angular.json +++ b/angular.json @@ -77,41 +77,45 @@ "polyfills": "apps/client/src/polyfills.ts", "tsConfig": "apps/client/tsconfig.app.json", "assets": [ - "apps/client/src/assets", { "glob": "assetlinks.json", "input": "apps/client/src/assets", - "output": "./.well-known" + "output": "./../.well-known" }, { "glob": "CHANGELOG.md", "input": "", - "output": "./assets" + "output": "./../assets" }, { "glob": "LICENSE", "input": "", - "output": "./assets" + "output": "./../assets" }, { "glob": "robots.txt", "input": "apps/client/src/assets", - "output": "./" + "output": "./../" }, { "glob": "sitemap.xml", "input": "apps/client/src/assets", - "output": "./" + "output": "./../" }, { "glob": "**/*", "input": "node_modules/ionicons/dist/ionicons", - "output": "./ionicons" + "output": "./../ionicons" }, { "glob": "**/*.js", "input": "node_modules/ionicons/dist/", - "output": "./" + "output": "./../" + }, + { + "glob": "**/*", + "input": "apps/client/src/assets", + "output": "./../assets/" } ], "styles": ["apps/client/src/styles.scss"], @@ -124,6 +128,14 @@ "namedChunks": true }, "configurations": { + "development-de": { + "baseHref": "/de/", + "localize": ["de"] + }, + "development-en": { + "baseHref": "/en/", + "localize": ["en"] + }, "production": { "fileReplacements": [ { @@ -162,15 +174,24 @@ "proxyConfig": "apps/client/proxy.conf.json" }, "configurations": { + "development-de": { + "browserTarget": "client:build:development-de" + }, + "development-en": { + "browserTarget": "client:build:development-en" + }, "production": { "browserTarget": "client:build:production" } } }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "ng-extract-i18n-merge:ng-extract-i18n-merge", "options": { - "browserTarget": "client:build" + "browserTarget": "client:build", + "includeContext": true, + "outputPath": "src/locales", + "targetFiles": ["messages.de.xlf"] } }, "lint": { @@ -188,6 +209,15 @@ "outputs": ["coverage/apps/client"] } }, + "i18n": { + "locales": { + "de": { + "baseHref": "/de/", + "translation": "apps/client/src/locales/messages.de.xlf" + } + }, + "sourceLocale": "en" + }, "tags": [] }, "client-e2e": { diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f1fc27976..e41b60b0e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { BullModule } from '@nestjs/bull'; -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; @@ -23,6 +23,7 @@ import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; import { ExportModule } from './export/export.module'; +import { FrontendMiddleware } from './frontend.middleware'; import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; import { OrderModule } from './order/order.module'; @@ -82,4 +83,10 @@ import { UserModule } from './user/user.module'; controllers: [AppController], providers: [CronService] }) -export class AppModule {} +export class AppModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(FrontendMiddleware) + .forRoutes({ path: '*', method: RequestMethod.ALL }); + } +} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 9cb6f8132..749f6f037 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { Body, @@ -62,9 +63,17 @@ export class AuthController { const jwt: string = req.user.jwt; if (jwt) { - res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`); + res.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + ); } else { - res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); + res.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth` + ); } } diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index f7a10d8e5..2e8ee61d4 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -48,9 +48,13 @@ export class BenchmarkService { benchmarks = allTimeHighs.map((allTimeHigh, index) => { const { marketPrice } = quotes[benchmarkAssets[index].symbol]; - const performancePercentFromAllTimeHigh = new Big(marketPrice) - .div(allTimeHigh) - .minus(1); + let performancePercentFromAllTimeHigh = new Big(0); + + if (allTimeHigh) { + performancePercentFromAllTimeHigh = new Big(marketPrice) + .div(allTimeHigh) + .minus(1); + } return { marketCondition: this.getMarketCondition( diff --git a/apps/api/src/app/frontend.middleware.ts b/apps/api/src/app/frontend.middleware.ts new file mode 100644 index 000000000..c45a35749 --- /dev/null +++ b/apps/api/src/app/frontend.middleware.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +@Injectable() +export class FrontendMiddleware implements NestMiddleware { + public indexHtmlDe = fs.readFileSync( + this.getPathOfIndexHtmlFile('de'), + 'utf8' + ); + public indexHtmlEn = fs.readFileSync( + this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), + 'utf8' + ); + + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public use(req: Request, res: Response, next: NextFunction) { + let featureGraphicPath = 'assets/cover.png'; + + if ( + req.path === '/en/blog/2022/08/500-stars-on-github' || + req.path === '/en/blog/2022/08/500-stars-on-github/' + ) { + featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; + } + + if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) { + // Skip + next(); + } else if (req.path === '/de' || req.path.startsWith('/de/')) { + res.send( + this.interpolate(this.indexHtmlDe, { + featureGraphicPath, + languageCode: 'de', + path: req.path, + rootUrl: this.configurationService.get('ROOT_URL') + }) + ); + } else { + res.send( + this.interpolate(this.indexHtmlEn, { + featureGraphicPath, + languageCode: DEFAULT_LANGUAGE_CODE, + path: req.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.includes('auth/ey')) { + return false; + } + + return filename.split('.').pop() !== filename; + } +} diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 6b12bd723..bf549200e 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -230,9 +230,10 @@ export class OrderService { }) }, { - SymbolProfileOverrides: { - is: null - } + OR: [ + { SymbolProfileOverrides: { is: null } }, + { SymbolProfileOverrides: { assetClass: null } } + ] } ] }, diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index aabc46d24..70317fe76 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,6 +1,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { PROPERTY_COUPONS } from '@ghostfolio/common/config'; +import { + DEFAULT_LANGUAGE_CODE, + PROPERTY_COUPONS +} from '@ghostfolio/common/config'; import { Coupon } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -93,7 +96,11 @@ export class SubscriptionController { 'SubscriptionController' ); - res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); + res.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/account` + ); } @Post('stripe/checkout-session') diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 5a4f75c20..fa061f369 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -1,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { Injectable, Logger } from '@nestjs/common'; import { Subscription } from '@prisma/client'; @@ -33,7 +34,9 @@ export class SubscriptionService { userId: string; }) { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { - cancel_url: `${this.configurationService.get('ROOT_URL')}/account`, + cancel_url: `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/account`, client_reference_id: userId, line_items: [ { diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index b97dd287e..f458294f8 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -9,6 +9,10 @@ export class UpdateUserSettingDto { @IsOptional() isRestrictedView?: boolean; + @IsString() + @IsOptional() + language?: string; + @IsString() @IsOptional() locale?: string; diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts index c91da6d61..1c8da554c 100644 --- a/apps/api/src/services/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile.service.ts @@ -115,9 +115,16 @@ export class SymbolProfileService { } item.name = item.SymbolProfileOverrides?.name ?? item.name; - item.sectors = - (item.SymbolProfileOverrides.sectors as unknown as Sector[]) ?? - item.sectors; + + if ( + (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > + 0 + ) { + item.sectors = item.SymbolProfileOverrides + .sectors as unknown as Sector[]; + } + + item.url = item.SymbolProfileOverrides?.url ?? item.url; delete item.SymbolProfileOverrides; } diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 558a3360d..da506e673 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -54,45 +54,52 @@ const routes: Routes = [ import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) }, { - path: 'de/blog/2021/07/hallo-ghostfolio', + path: 'blog/2021/07/hallo-ghostfolio', loadChildren: () => import( './pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module' ).then((m) => m.HalloGhostfolioPageModule) }, { - path: 'demo', - loadChildren: () => - import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) - }, - { - path: 'en/blog/2021/07/hello-ghostfolio', + path: 'blog/2021/07/hello-ghostfolio', loadChildren: () => import( './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' ).then((m) => m.HelloGhostfolioPageModule) }, { - path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source', + path: 'blog/2022/01/ghostfolio-first-months-in-open-source', loadChildren: () => import( './pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module' ).then((m) => m.FirstMonthsInOpenSourcePageModule) }, { - path: 'en/blog/2022/07/ghostfolio-meets-internet-identity', + path: 'blog/2022/07/ghostfolio-meets-internet-identity', loadChildren: () => import( './pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module' ).then((m) => m.GhostfolioMeetsInternetIdentityPageModule) }, { - path: 'en/blog/2022/07/how-do-i-get-my-finances-in-order', + path: 'blog/2022/07/how-do-i-get-my-finances-in-order', loadChildren: () => import( './pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module' ).then((m) => m.HowDoIGetMyFinancesInOrderPageModule) }, + { + path: 'blog/2022/08/500-stars-on-github', + loadChildren: () => + import( + './pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module' + ).then((m) => m.FiveHundredStarsOnGitHubPageModule) + }, + { + path: 'demo', + loadChildren: () => + import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) + }, { path: 'faq', loadChildren: () => diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 04d3d01ca..4525930cf 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -24,8 +24,8 @@ class="cursor-pointer d-inline-block info-message px-3 py-2" (click)="onCreateAccount()" > - You are using the Live Demo. - Create Account + You are using the Live Demo. + Create Account
Ghostfolio is a lightweight wealth management application for
@@ -21,7 +21,7 @@
Ghostfolio in Numbers
+ Ghostfolio in Numbers
License
Index | -- Change from All Time High - from ATH - | -- |
---|---|---|
-
- {{ benchmark.name }}
-
- |
-
- |
-
-
- {{ resolveMarketCondition(benchmark.marketCondition).emoji }}
-
- |
-
Index | ++ {{ element?.name }} + | ++ Change from All Time High + from ATH + | +
+ |
+ + |
+
+ {{ resolveMarketCondition(element.marketCondition).emoji }}
+
+ |
+
---|