mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
103 changed files with 3732 additions and 2182 deletions
@ -0,0 +1,85 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; |
|||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; |
|||
import { parseSymbol } from '@ghostfolio/common/helper'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { SymbolProfile } from '@prisma/client'; |
|||
import got, { Headers } from 'got'; |
|||
|
|||
@Injectable() |
|||
export class OpenFigiDataEnhancerService implements DataEnhancerInterface { |
|||
private static baseUrl = 'https://api.openfigi.com'; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService |
|||
) {} |
|||
|
|||
public async enhance({ |
|||
response, |
|||
symbol |
|||
}: { |
|||
response: Partial<SymbolProfile>; |
|||
symbol: string; |
|||
}): Promise<Partial<SymbolProfile>> { |
|||
if ( |
|||
!( |
|||
response.assetClass === 'EQUITY' && |
|||
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK') |
|||
) |
|||
) { |
|||
return response; |
|||
} |
|||
|
|||
const headers: Headers = {}; |
|||
const { exchange, ticker } = parseSymbol({ |
|||
symbol, |
|||
dataSource: response.dataSource |
|||
}); |
|||
|
|||
if (this.configurationService.get('OPEN_FIGI_API_KEY')) { |
|||
headers['X-OPENFIGI-APIKEY'] = |
|||
this.configurationService.get('OPEN_FIGI_API_KEY'); |
|||
} |
|||
|
|||
let abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, DEFAULT_REQUEST_TIMEOUT); |
|||
|
|||
const mappings = await got |
|||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, { |
|||
headers, |
|||
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }], |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
}) |
|||
.json<any[]>(); |
|||
|
|||
if (mappings?.length === 1 && mappings[0].data?.length === 1) { |
|||
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; |
|||
|
|||
if (figi) { |
|||
response.figi = figi; |
|||
} |
|||
|
|||
if (compositeFIGI) { |
|||
response.figiComposite = compositeFIGI; |
|||
} |
|||
|
|||
if (shareClassFIGI) { |
|||
response.figiShareClass = shareClassFIGI; |
|||
} |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
public getName() { |
|||
return 'OPENFIGI'; |
|||
} |
|||
|
|||
public getTestSymbol() { |
|||
return undefined; |
|||
} |
|||
} |
@ -0,0 +1,67 @@ |
|||
import { readFileSync, readdirSync } from 'fs'; |
|||
import { join } from 'path'; |
|||
|
|||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|||
import { Logger } from '@nestjs/common'; |
|||
import * as cheerio from 'cheerio'; |
|||
|
|||
export class I18nService { |
|||
private localesPath = join(__dirname, 'assets', 'locales'); |
|||
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; |
|||
|
|||
public constructor() { |
|||
this.loadFiles(); |
|||
} |
|||
|
|||
public getTranslation({ |
|||
id, |
|||
languageCode |
|||
}: { |
|||
id: string; |
|||
languageCode: string; |
|||
}): string { |
|||
const $ = this.translations[languageCode]; |
|||
|
|||
if (!$) { |
|||
Logger.warn(`Translation not found for locale '${languageCode}'`); |
|||
} |
|||
|
|||
const translatedText = $( |
|||
`trans-unit[id="${id}"] > ${ |
|||
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target' |
|||
}` |
|||
).text(); |
|||
|
|||
if (!translatedText) { |
|||
Logger.warn( |
|||
`Translation not found for id '${id}' in locale '${languageCode}'` |
|||
); |
|||
} |
|||
|
|||
return translatedText.trim(); |
|||
} |
|||
|
|||
private loadFiles() { |
|||
try { |
|||
const files = readdirSync(this.localesPath, 'utf-8'); |
|||
|
|||
for (const file of files) { |
|||
const xmlData = readFileSync(join(this.localesPath, file), 'utf8'); |
|||
this.translations[this.parseLanguageCode(file)] = |
|||
this.parseXml(xmlData); |
|||
} |
|||
} catch (error) { |
|||
Logger.error(error, 'I18nService'); |
|||
} |
|||
} |
|||
|
|||
private parseLanguageCode(aFileName: string) { |
|||
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/); |
|||
|
|||
return match ? match[1] : DEFAULT_LANGUAGE_CODE; |
|||
} |
|||
|
|||
private parseXml(xmlData: string): cheerio.CheerioAPI { |
|||
return cheerio.load(xmlData, { xmlMode: true }); |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -1,7 +1,9 @@ |
|||
:host { |
|||
display: flex; |
|||
flex: 0 0 auto; |
|||
margin-bottom: 0; |
|||
min-height: 0; |
|||
padding: 0 !important; |
|||
|
|||
@media (min-width: 576px) { |
|||
padding: 0 !important; |
|||
} |
|||
} |
|||
|
@ -0,0 +1,19 @@ |
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule, Routes } from '@angular/router'; |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
|
|||
import { I18nPageComponent } from './i18n-page.component'; |
|||
|
|||
const routes: Routes = [ |
|||
{ |
|||
canActivate: [AuthGuard], |
|||
component: I18nPageComponent, |
|||
path: '' |
|||
} |
|||
]; |
|||
|
|||
@NgModule({ |
|||
imports: [RouterModule.forChild(routes)], |
|||
exports: [RouterModule] |
|||
}) |
|||
export class I18nPageRoutingModule {} |
@ -0,0 +1,21 @@ |
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { Subject } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
selector: 'gf-i18n-page', |
|||
styleUrls: ['./i18n-page.scss'], |
|||
templateUrl: './i18n-page.html' |
|||
}) |
|||
export class I18nPageComponent implements OnInit { |
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor() {} |
|||
|
|||
public ngOnInit() {} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
} |
@ -0,0 +1,10 @@ |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<ul> |
|||
<li i18n="@@metaDescription"> |
|||
Ghostfolio is a personal finance dashboard to keep track of your assets |
|||
like stocks, ETFs or cryptocurrencies across multiple platforms. |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
@ -0,0 +1,12 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
|
|||
import { I18nPageRoutingModule } from './i18n-page-routing.module'; |
|||
import { I18nPageComponent } from './i18n-page.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [I18nPageComponent], |
|||
imports: [CommonModule, I18nPageRoutingModule], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class I18nPageModule {} |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -1,6 +1,5 @@ |
|||
export const environment = { |
|||
lastPublish: '{BUILD_TIMESTAMP}', |
|||
production: true, |
|||
stripePublicKey: '', |
|||
version: `v${require('../../../../package.json').version}` |
|||
stripePublicKey: '' |
|||
}; |
|||
|
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,4 @@ |
|||
export interface Currency { |
|||
label: string; |
|||
value: string; |
|||
} |
@ -1,12 +1,16 @@ |
|||
<a |
|||
#link |
|||
class="d-block px-2 py-1 text-truncate" |
|||
[queryParams]="{ |
|||
dataSource: holding?.dataSource, |
|||
positionDetailDialog: true, |
|||
symbol: holding?.symbol |
|||
}" |
|||
[routerLink]="['/portfolio', 'holdings']" |
|||
class="d-block line-height-1 px-2 py-1 text-truncate" |
|||
[queryParams]="queryParams" |
|||
[routerLink]="routerLink" |
|||
(click)="onClick()" |
|||
>{{ holding?.name }}</a |
|||
><span><b>{{ item?.name }}</b></span> |
|||
<br /> |
|||
<small class="text-muted" |
|||
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }}<ng-container |
|||
*ngIf="item?.assetSubClassString" |
|||
> |
|||
· {{ item?.assetSubClassString }}</ng-container |
|||
></small |
|||
></a |
|||
> |
|||
|
@ -1,12 +1,13 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; |
|||
|
|||
import { AssistantListItemComponent } from './assistant-list-item.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [AssistantListItemComponent], |
|||
exports: [AssistantListItemComponent], |
|||
imports: [CommonModule, RouterModule] |
|||
imports: [CommonModule, GfSymbolModule, RouterModule] |
|||
}) |
|||
export class GfAssistantListItemModule {} |
|||
|
@ -1,5 +1,12 @@ |
|||
import { Position } from '@ghostfolio/common/interfaces'; |
|||
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface ISearchResultItem extends UniqueAsset { |
|||
assetSubClassString: string; |
|||
currency: string; |
|||
name: string; |
|||
} |
|||
|
|||
export interface ISearchResults { |
|||
holdings: Position[]; |
|||
assetProfiles: ISearchResultItem[]; |
|||
holdings: ISearchResultItem[]; |
|||
} |
|||
|
@ -0,0 +1,21 @@ |
|||
<input |
|||
autocapitalize="off" |
|||
autocomplete="off" |
|||
matInput |
|||
[formControl]="control" |
|||
[matAutocomplete]="currencyAutocomplete" |
|||
/> |
|||
|
|||
<mat-autocomplete |
|||
#currencyAutocomplete="matAutocomplete" |
|||
[displayWith]="displayFn" |
|||
(optionSelected)="onUpdateCurrency($event)" |
|||
> |
|||
<mat-option |
|||
*ngFor="let currencyItem of filteredCurrencies" |
|||
class="line-height-1" |
|||
[value]="currencyItem" |
|||
> |
|||
{{ currencyItem.label }} |
|||
</mat-option> |
|||
</mat-autocomplete> |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,167 @@ |
|||
import { FocusMonitor } from '@angular/cdk/a11y'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { FormControl, FormGroupDirective, NgControl } from '@angular/forms'; |
|||
import { |
|||
MatAutocomplete, |
|||
MatAutocompleteSelectedEvent |
|||
} from '@angular/material/autocomplete'; |
|||
import { MatFormFieldControl } from '@angular/material/form-field'; |
|||
import { MatInput } from '@angular/material/input'; |
|||
import { Currency } from '@ghostfolio/common/interfaces/currency.interface'; |
|||
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field'; |
|||
import { Subject } from 'rxjs'; |
|||
import { map, startWith, takeUntil } from 'rxjs/operators'; |
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
host: { |
|||
'[attr.aria-describedBy]': 'describedBy', |
|||
'[id]': 'id' |
|||
}, |
|||
providers: [ |
|||
{ |
|||
provide: MatFormFieldControl, |
|||
useExisting: CurrencySelectorComponent |
|||
} |
|||
], |
|||
selector: 'gf-currency-selector', |
|||
styleUrls: ['./currency-selector.component.scss'], |
|||
templateUrl: 'currency-selector.component.html' |
|||
}) |
|||
export class CurrencySelectorComponent |
|||
extends AbstractMatFormField<Currency> |
|||
implements OnInit, OnDestroy |
|||
{ |
|||
@Input() private currencies: Currency[] = []; |
|||
@Input() private formControlName: string; |
|||
|
|||
@ViewChild(MatInput) private input: MatInput; |
|||
|
|||
@ViewChild('currencyAutocomplete') |
|||
public currencyAutocomplete: MatAutocomplete; |
|||
|
|||
public control = new FormControl(); |
|||
public filteredCurrencies: Currency[] = []; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
public readonly _elementRef: ElementRef, |
|||
public readonly _focusMonitor: FocusMonitor, |
|||
public readonly changeDetectorRef: ChangeDetectorRef, |
|||
private readonly formGroupDirective: FormGroupDirective, |
|||
public readonly ngControl: NgControl |
|||
) { |
|||
super(_elementRef, _focusMonitor, ngControl); |
|||
|
|||
this.controlType = 'currency-selector'; |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
if (this.disabled) { |
|||
this.control.disable(); |
|||
} |
|||
|
|||
const formGroup = this.formGroupDirective.form; |
|||
|
|||
if (formGroup) { |
|||
const control = formGroup.get(this.formControlName); |
|||
|
|||
if (control) { |
|||
this.value = this.currencies.find(({ value }) => { |
|||
return value === control.value; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
this.control.valueChanges |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
if (super.value?.value) { |
|||
super.value.value = null; |
|||
} |
|||
}); |
|||
|
|||
this.control.valueChanges |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
startWith(''), |
|||
map((value) => { |
|||
return value ? this.filter(value) : this.currencies.slice(); |
|||
}) |
|||
) |
|||
.subscribe((values) => { |
|||
this.filteredCurrencies = values; |
|||
}); |
|||
} |
|||
|
|||
public displayFn(currency: Currency) { |
|||
return currency?.label ?? ''; |
|||
} |
|||
|
|||
public get empty() { |
|||
return this.input?.empty; |
|||
} |
|||
|
|||
public focus() { |
|||
this.input.focus(); |
|||
} |
|||
|
|||
public ngDoCheck() { |
|||
if (this.ngControl) { |
|||
this.validateRequired(); |
|||
this.errorState = this.ngControl.invalid && this.ngControl.touched; |
|||
this.stateChanges.next(); |
|||
} |
|||
} |
|||
|
|||
public onUpdateCurrency(event: MatAutocompleteSelectedEvent) { |
|||
super.value = { |
|||
label: event.option.value.label, |
|||
value: event.option.value.value |
|||
} as Currency; |
|||
} |
|||
|
|||
public set value(value: Currency) { |
|||
const newValue = |
|||
typeof value === 'object' && value !== null ? { ...value } : value; |
|||
this.control.setValue(newValue); |
|||
super.value = newValue; |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
super.ngOnDestroy(); |
|||
|
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private filter(value: Currency | string) { |
|||
const filterValue = |
|||
typeof value === 'string' |
|||
? value?.toLowerCase() |
|||
: value?.value.toLowerCase(); |
|||
|
|||
return this.currencies.filter((currency) => { |
|||
return currency.value.toLowerCase().startsWith(filterValue); |
|||
}); |
|||
} |
|||
|
|||
private validateRequired() { |
|||
const requiredCheck = super.required |
|||
? !super.value.label || !super.value.value |
|||
: false; |
|||
|
|||
if (requiredCheck) { |
|||
this.ngControl.control.setErrors({ invalidData: true }); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,23 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { MatAutocompleteModule } from '@angular/material/autocomplete'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatInputModule } from '@angular/material/input'; |
|||
|
|||
import { CurrencySelectorComponent } from './currency-selector.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [CurrencySelectorComponent], |
|||
exports: [CurrencySelectorComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
MatAutocompleteModule, |
|||
MatFormFieldModule, |
|||
MatInputModule, |
|||
ReactiveFormsModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class GfCurrencySelectorModule {} |
@ -1,4 +1,4 @@ |
|||
<span class="align-items-center d-flex" |
|||
><span class="d-inline-block logo mr-1"></span> |
|||
><span class="d-inline-block logo" [ngClass]="{ 'mr-1': showLabel }"></span> |
|||
<span *ngIf="showLabel" class="label">{{ label ?? 'Ghostfolio' }}</span></span |
|||
> |
|||
|
@ -0,0 +1 @@ |
|||
export * from './membership-card.module'; |
@ -0,0 +1,29 @@ |
|||
<div |
|||
class="card-container position-relative" |
|||
[ngClass]="{ premium: name === 'Premium' }" |
|||
> |
|||
<a |
|||
class="card-item d-flex flex-column justify-content-between p-4" |
|||
[routerLink]="routerLinkPricing" |
|||
> |
|||
<div class="d-flex justify-content-end"> |
|||
<gf-logo |
|||
size="large" |
|||
[ngClass]="{ 'text-muted': name === 'Basic' }" |
|||
[showLabel]="false" |
|||
/> |
|||
</div> |
|||
<div class="d-flex justify-content-between"> |
|||
<div> |
|||
<div class="heading text-muted" i18n>Membership</div> |
|||
<div class="text-truncate value">{{ name }}</div> |
|||
</div> |
|||
<div *ngIf="expiresAt"> |
|||
<div class="heading text-muted" i18n>Valid until</div> |
|||
<div class="text-truncate value"> |
|||
{{ expiresAt }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</a> |
|||
</div> |
@ -0,0 +1,66 @@ |
|||
:host { |
|||
--borderRadius: 1rem; |
|||
--borderWidth: 2px; |
|||
|
|||
display: block; |
|||
max-width: 25rem; |
|||
padding-top: calc(1 * var(--borderWidth)); |
|||
width: 100%; |
|||
|
|||
.card-container { |
|||
border-radius: var(--borderRadius); |
|||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); |
|||
|
|||
&:after { |
|||
animation: animatedborder 7s ease alternate infinite; |
|||
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82); |
|||
background-size: 300% 300%; |
|||
border-radius: var(--borderRadius); |
|||
content: ''; |
|||
height: calc(100% + var(--borderWidth) * 2); |
|||
left: calc(-1 * var(--borderWidth)); |
|||
top: calc(-1 * var(--borderWidth)); |
|||
position: absolute; |
|||
width: calc(100% + var(--borderWidth) * 2); |
|||
z-index: -1; |
|||
|
|||
@keyframes animatedborder { |
|||
0% { |
|||
background-position: 0% 50%; |
|||
} |
|||
50% { |
|||
background-position: 100% 50%; |
|||
} |
|||
100% { |
|||
background-position: 0% 50%; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.card-item { |
|||
aspect-ratio: 1.586; |
|||
background-color: #1d2124; |
|||
border-radius: calc(var(--borderRadius) - var(--borderWidth)); |
|||
color: rgba(var(--light-primary-text)); |
|||
|
|||
.heading { |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.value { |
|||
font-size: 18px; |
|||
} |
|||
} |
|||
|
|||
&:not(.premium) { |
|||
&:after { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.card-item { |
|||
background-color: #ffffff; |
|||
color: rgba(var(--dark-primary-text)); |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
selector: 'gf-membership-card', |
|||
styleUrls: ['./membership-card.component.scss'], |
|||
templateUrl: './membership-card.component.html' |
|||
}) |
|||
export class MembershipCardComponent { |
|||
@Input() public expiresAt: string; |
|||
@Input() public name: string; |
|||
|
|||
public routerLinkPricing = ['/' + $localize`pricing`]; |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfLogoModule } from '@ghostfolio/ui/logo'; |
|||
|
|||
import { MembershipCardComponent } from './membership-card.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [MembershipCardComponent], |
|||
exports: [MembershipCardComponent], |
|||
imports: [CommonModule, GfLogoModule, RouterModule], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class GfMembershipCardModule {} |
@ -0,0 +1,5 @@ |
|||
-- AlterTable |
|||
ALTER TABLE "SymbolProfile" |
|||
ADD COLUMN "figi" TEXT, |
|||
ADD COLUMN "figiComposite" TEXT, |
|||
ADD COLUMN "figiShareClass" TEXT; |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue