diff --git a/package-lock.json b/package-lock.json index 9aec185031..3ffd3e27e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "dsp-app", "version": "5.3.0", "dependencies": { "@angular/animations": "^11.2.9", @@ -4916,6 +4917,7 @@ "version": "9.0.0", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "tslib": "^1.10.0" } @@ -4924,6 +4926,7 @@ "version": "9.0.0", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "rxjs": "^6.5.3", "tslib": "^1.10.0", @@ -12311,6 +12314,7 @@ "node_modules/pdfjs-dist": { "version": "2.7.570", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "worker-loader": "^3.0.7" } @@ -21888,8 +21892,6 @@ "version": "6.0.2", "dev": true, "requires": { - "@angular/compiler": "9.0.0", - "@angular/core": "9.0.0", "app-root-path": "^3.0.0", "aria-query": "^3.0.0", "axobject-query": "2.0.2", @@ -21907,11 +21909,13 @@ "@angular/compiler": { "version": "9.0.0", "dev": true, + "peer": true, "requires": {} }, "@angular/core": { "version": "9.0.0", "dev": true, + "peer": true, "requires": {} }, "source-map": { @@ -26180,7 +26184,6 @@ "ng2-pdf-viewer": { "version": "7.0.1", "requires": { - "pdfjs-dist": "~2.7.570", "tslib": "^2.0.0" } }, @@ -26985,6 +26988,7 @@ }, "pdfjs-dist": { "version": "2.7.570", + "peer": true, "requires": {} }, "performance-now": { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6969afeda0..ef3c6ba794 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -98,6 +98,22 @@ import { ResultsComponent } from './workspace/results/results.component'; import { AudioComponent } from './workspace/resource/representation/audio/audio.component'; import { IntermediateComponent } from './workspace/intermediate/intermediate.component'; import { ResourceLinkFormComponent } from './workspace/resource/resource-link-form/resource-link-form.component'; +import { ConfirmationDialogComponent } from './main/action/confirmation-dialog/confirmation-dialog.component'; +import { ConfirmationMessageComponent } from './main/action/confirmation-dialog/confirmation-message/confirmation-message.component'; +import { LoginFormComponent } from './main/action/login-form/login-form.component'; +import { MessageComponent } from './main/action/message/message.component'; +import { ProgressIndicatorComponent } from './main/action/progress-indicator/progress-indicator.component'; +import { StringLiteralInputComponent } from './main/action/string-literal-input/string-literal-input.component'; +import { SortButtonComponent } from './main/action/sort-button/sort-button.component'; +import { SelectedResourcesComponent } from './main/action/selected-resources/selected-resources.component'; +import { AdminImageDirective } from './main/directive/admin-image/admin-image.directive'; +import { ExistingNameDirective } from './main/directive/existing-name/existing-name.directive'; +import { GndDirective } from './main/directive/gnd/gnd.directive'; +import { FormattedBooleanPipe } from './main/pipes/formatting/formatted-boolean.pipe'; +import { KnoraDatePipe } from './main/pipes/formatting/knoradate.pipe'; +import { LinkifyPipe } from './main/pipes/string-transformation/linkify.pipe'; +import { StringifyStringLiteralPipe } from './main/pipes/string-transformation/stringify-string-literal.pipe'; +import { TruncatePipe } from './main/pipes/string-transformation/truncate.pipe'; // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { @@ -182,6 +198,22 @@ export function httpLoaderFactory(httpClient: HttpClient) { AudioComponent, IntermediateComponent, ResourceLinkFormComponent, + ConfirmationDialogComponent, + ConfirmationMessageComponent, + LoginFormComponent, + MessageComponent, + ProgressIndicatorComponent, + StringLiteralInputComponent, + SortButtonComponent, + SelectedResourcesComponent, + AdminImageDirective, + ExistingNameDirective, + GndDirective, + FormattedBooleanPipe, + KnoraDatePipe, + LinkifyPipe, + StringifyStringLiteralPipe, + TruncatePipe, ], imports: [ AppRoutingModule, diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html new file mode 100644 index 0000000000..cd02c1ec84 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.html @@ -0,0 +1,11 @@ +
+ +

Are you sure you want to delete this value from {{data.value.propertyLabel}}?

+ +
+ + + + +
+ diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss new file mode 100644 index 0000000000..0a5a960ec2 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.scss @@ -0,0 +1 @@ +@import "../../../../assets/style/viewer"; diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts new file mode 100644 index 0000000000..dac46a0528 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.spec.ts @@ -0,0 +1,168 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, Input, OnInit } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatDialogHarness } from '@angular/material/dialog/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MockResource, ReadIntValue, ReadValue } from '@dasch-swiss/dsp-js'; +import { ConfirmationDialogComponent, ConfirmationDialogValueDeletionPayload } from './confirmation-dialog.component'; + +/** + * test host component to simulate parent component with a confirmation dialog. + */ +@Component({ + template: ` +

{{confirmationDialogResponse}}

` +}) +class ConfirmationDialogTestHostComponent implements OnInit { + + confirmationDialogResponse: string; + + testValue: ReadIntValue; + + constructor(private dialog: MatDialog) { + } + + ngOnInit() { + MockResource.getTestThing().subscribe(res => { + this.testValue = res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', ReadIntValue)[0]; + }); + } + + openDialog() { + + this.dialog.open(ConfirmationDialogComponent, { + data: { + value: this.testValue, + buttonTextOk: 'OK', + buttonTextCancel: 'Cancel' + } + }).afterClosed().subscribe((payload: ConfirmationDialogValueDeletionPayload) => { + if (payload.confirmed) { + this.confirmationDialogResponse = 'Action was confirmed!'; + } else { + this.confirmationDialogResponse = 'Action was cancelled'; + } + }); + } +} + +@Component({ selector: 'app-confirmation-message', template: '' }) +class MockConfirmationMessageComponent { + @Input() value: ReadValue; + + constructor() { } +} + +describe('ConfirmationDialogComponent', () => { + let testHostComponent: ConfirmationDialogTestHostComponent; + let testHostFixture: ComponentFixture; + let rootLoader: HarnessLoader; + let overlayContainer: OverlayContainer; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfirmationDialogComponent, + ConfirmationDialogTestHostComponent, + MockConfirmationMessageComponent + ], + imports: [ + MatDialogModule, + BrowserAnimationsModule + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: {} + }, + { + provide: MatDialogRef, + useValue: {} + } + ] + }) + .compileComponents(); + + overlayContainer = TestBed.inject(OverlayContainer); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(ConfirmationDialogTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + rootLoader = TestbedHarnessEnvironment.documentRootLoader(testHostFixture); + testHostFixture.detectChanges(); + expect(testHostComponent).toBeTruthy(); + }); + + afterEach(async () => { + const dialogs = await rootLoader.getAllHarnesses(MatDialogHarness); + await Promise.all(dialogs.map(async d => await d.close())); + + // angular won't call this for us so we need to do it ourselves to avoid leaks. + overlayContainer.ngOnDestroy(); + }); + + it('should display a confirmation dialog', async () => { + + testHostComponent.openDialog(); + + testHostFixture.detectChanges(); + + await testHostFixture.whenStable(); + + const dialogDiv = document.querySelector('mat-dialog-container'); + expect(dialogDiv).toBeTruthy(); + + const dspConfirmMsg = document.querySelector('app-confirmation-message'); + expect(dspConfirmMsg).toBeTruthy(); + + const dialogTitle = dialogDiv.querySelector('.title'); + expect(dialogTitle.innerHTML.trim()).toEqual('Are you sure you want to delete this value from Integer?'); + + }); + + it('should return a confirmation message when the OK button is clicked', async () => { + + testHostComponent.openDialog(); + + let dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness); + + expect(dialogHarnesses.length).toEqual(1); + + const okButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.ok' })); + + await okButton.click(); + + dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness); + + expect(dialogHarnesses.length).toEqual(0); + + expect(testHostComponent.confirmationDialogResponse).toEqual('Action was confirmed!'); + + }); + + it('should return a cancelled message when the cancel button is clicked', async () => { + + testHostComponent.openDialog(); + + let dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness); + + expect(dialogHarnesses.length).toEqual(1); + + const cancelButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.cancel' })); + + await cancelButton.click(); + + dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness); + + expect(dialogHarnesses.length).toEqual(0); + + expect(testHostComponent.confirmationDialogResponse).toEqual('Action was cancelled'); + + + }); +}); diff --git a/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts new file mode 100644 index 0000000000..79ac60c1b2 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-dialog.component.ts @@ -0,0 +1,39 @@ +import { Component, Inject, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ReadValue } from '@dasch-swiss/dsp-js'; +import { ConfirmationMessageComponent } from './confirmation-message/confirmation-message.component'; + +export class ConfirmationDialogData { + value: ReadValue; + buttonTextOk: string; + buttonTextCancel: string; +} + +export class ConfirmationDialogValueDeletionPayload { + confirmed: boolean; + deletionComment?: string; +} + +@Component({ + selector: 'app-confirmation-dialog', + templateUrl: './confirmation-dialog.component.html', + styleUrls: ['./confirmation-dialog.component.scss'] +}) +export class ConfirmationDialogComponent { + @ViewChild('confirmMessage') confirmationMessageComponent: ConfirmationMessageComponent; + + // type assertion doesn't seem to be enforced + // https://stackoverflow.com/a/57787554 + constructor( + @Inject(MAT_DIALOG_DATA) public data: ConfirmationDialogData, + private _dialogRef: MatDialogRef + ) { } + + onConfirmClick(): void { + const payload = new ConfirmationDialogValueDeletionPayload(); + payload.confirmed = true; + payload.deletionComment = this.confirmationMessageComponent.comment ? this.confirmationMessageComponent.comment : undefined; + this._dialogRef.close(payload); + } + +} diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html new file mode 100644 index 0000000000..49fdbbf3ef --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.html @@ -0,0 +1,13 @@ +
+

Confirming this action will delete the following value from {{value.propertyLabel}}:

+

Value: {{value.strval}}

+

Value Comment: {{value.valueHasComment ? value.valueHasComment : 'No comment'}}

+

Value Creation Date: {{value.valueCreationDate}}

+ +
diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts new file mode 100644 index 0000000000..1f2d313ec5 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.spec.ts @@ -0,0 +1,88 @@ +import { Component, OnInit } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MockResource, ReadIntValue } from '@dasch-swiss/dsp-js'; +import { ConfirmationMessageComponent } from './confirmation-message.component'; + +/** + * test host component to simulate parent component with a confirmation dialog. + */ +@Component({ + template: ` + ` +}) +class ConfirmationMessageTestHostComponent implements OnInit { + + testValue: ReadIntValue; + + constructor() { + } + + ngOnInit() { + MockResource.getTestThing().subscribe(res => { + this.testValue = res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', ReadIntValue)[0]; + }); + } +} + +describe('ConfirmationMessageComponent', () => { + let testHostComponent: ConfirmationMessageTestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfirmationMessageTestHostComponent, + ConfirmationMessageComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(ConfirmationMessageTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + }); + + it('should bind the values correctly', () => { + testHostComponent.testValue.valueHasComment = 'My comment'; + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + const valueComponentDe = hostCompDe.query(By.directive(ConfirmationMessageComponent)); + + expect(valueComponentDe).toBeTruthy(); + + const label = valueComponentDe.query(By.css('.val-label')).nativeElement; + expect(label.innerText).toEqual('Confirming this action will delete the following value from Integer:'); + + const value = valueComponentDe.query(By.css('.val-value')).nativeElement; + expect(value.innerText).toEqual('Value: 1'); + + const comment = valueComponentDe.query(By.css('.val-comment')).nativeElement; + expect(comment.innerText).toEqual('Value Comment: My comment'); + + const creationDate = valueComponentDe.query(By.css('.val-creation-date')).nativeElement; + expect(creationDate.innerText).toEqual('Value Creation Date: 2018-05-28T15:52:03.897Z'); + + }); + + it('should default to "no comment" if the value does not contain a comment', () => { + testHostComponent.testValue.valueHasComment = null; + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + const valueComponentDe = hostCompDe.query(By.directive(ConfirmationMessageComponent)); + + expect(valueComponentDe).toBeTruthy(); + + const comment = valueComponentDe.query(By.css('.val-comment')).nativeElement; + expect(comment.innerText).toEqual('Value Comment: No comment'); + + }); +}); diff --git a/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts new file mode 100644 index 0000000000..87b7a62339 --- /dev/null +++ b/src/app/main/action/confirmation-dialog/confirmation-message/confirmation-message.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import { ReadValue } from '@dasch-swiss/dsp-js'; + +@Component({ + selector: 'app-confirmation-message', + templateUrl: './confirmation-message.component.html', + styleUrls: ['./confirmation-message.component.scss'] +}) +export class ConfirmationMessageComponent { + + @Input() value: ReadValue; + comment?: string; + + constructor() { } + + onKey(event: KeyboardEvent) { + this.comment = (event.target as HTMLInputElement).value; + } + +} diff --git a/src/app/main/action/login-form/login-form.component.html b/src/app/main/action/login-form/login-form.component.html new file mode 100644 index 0000000000..347f1b3bdd --- /dev/null +++ b/src/app/main/action/login-form/login-form.component.html @@ -0,0 +1,67 @@ + + + +
+

A user is already logged in:

+

Username: {{session.user.name}}

+
+

Please log out if it's not you.

+ +
diff --git a/src/app/main/action/login-form/login-form.component.scss b/src/app/main/action/login-form/login-form.component.scss new file mode 100644 index 0000000000..7378eb40f1 --- /dev/null +++ b/src/app/main/action/login-form/login-form.component.scss @@ -0,0 +1,38 @@ +$warn: #ef5350; + +$width: 280px; + +.full-width { + width: 100%; +} + +.login-form-title { + text-align: center; +} + +.login-error { + color: $warn; + text-align: center; +} + +.login-progress { + display: inline-block; + margin-right: 6px; +} + +.login-container, +.logout-container { + margin-left: auto; + margin-right: auto; + position: relative; + width: $width; +} + +.submit-button, +.logout-button { + height: 48px; +} + +.center { + text-align: center; +} diff --git a/src/app/main/action/login-form/login-form.component.spec.ts b/src/app/main/action/login-form/login-form.component.spec.ts new file mode 100644 index 0000000000..b32d8c7b6e --- /dev/null +++ b/src/app/main/action/login-form/login-form.component.spec.ts @@ -0,0 +1,225 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ApiResponseData, + AuthenticationEndpointV2, + LoginResponse, + LogoutResponse, + MockUsers, + UsersEndpointAdmin +} from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { AjaxResponse } from 'rxjs/ajax'; +import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens'; +import { Session, SessionService } from '../../services/session.service'; +import { LoginFormComponent } from './login-form.component'; + +/** + * test host component to simulate login-form component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('loginForm') loginFormComponent: LoginFormComponent; + + loggedIn: boolean; + + ngOnInit() { } + + // if response is true, the login was successful + // assign loggedIn to the response + onLogin(response: boolean) { + this.loggedIn = response; + } + + // if response is true, the logout was successful + // assign loggedIn to the opposite of the response + onLogout(response: boolean) { + this.loggedIn = !response; + } + +} + +describe('LoginFormComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let sessionService: SessionService; + + beforeEach(waitForAsync(() => { + const dspConnSpy = { + admin: { + usersEndpoint: jasmine.createSpyObj('usersEndpoint', ['getUser']) + }, + v2: { + auth: jasmine.createSpyObj('auth', ['login', 'logout']), + jsonWebToken: '' + }, + }; + + TestBed.configureTestingModule({ + declarations: [ + LoginFormComponent, + TestHostComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + }, + FormBuilder, + SessionService + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + MatSnackBarModule, + BrowserAnimationsModule + ] + }) + .compileComponents(); + + sessionService = TestBed.inject(SessionService); + })); + + // mock localStorage + beforeEach(() => { + let store = {}; + + spyOn(localStorage, 'getItem').and.callFake( + (key: string): string => store[key] || null + ); + spyOn(localStorage, 'removeItem').and.callFake( + (key: string): void => { + delete store[key]; + } + ); + spyOn(localStorage, 'setItem').and.callFake( + (key: string, value: string): void => { + store[key] = value; + } + ); + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + }); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create an instance', () => { + expect(testHostComponent.loginFormComponent).toBeTruthy(); + }); + + describe('Login', () => { + + beforeEach(() => { + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + (dspConnSpy.v2.auth as jasmine.SpyObj).login.and.callFake( + () => { + const response: LoginResponse = new LoginResponse(); + + response.token = 'myToken'; + + return of(ApiResponseData.fromAjaxResponse({ response } as AjaxResponse)); + } + ); + + (dspConnSpy.admin.usersEndpoint as jasmine.SpyObj).getUser.and.callFake( + () => { + const loggedInUser = MockUsers.mockUser(); + return of(loggedInUser); + } + ); + }); + + it('should log the user in if the credentials are correct', () => { + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + testHostComponent.loginFormComponent.form.get('username').setValue('root'); + + testHostComponent.loginFormComponent.form.get('password').setValue('test'); + + testHostFixture.detectChanges(); + + testHostComponent.loginFormComponent.login(); + + expect(dspConnSpy.v2.auth.login).toHaveBeenCalledTimes(1); + + expect(dspConnSpy.v2.auth.login).toHaveBeenCalledWith('username', 'root', 'test'); + + expect(dspConnSpy.admin.usersEndpoint.getUser).toHaveBeenCalledTimes(1); + + expect(dspConnSpy.admin.usersEndpoint.getUser).toHaveBeenCalledWith('username', 'root'); + + const session = JSON.parse(localStorage.getItem('session')); + + expect(session.user.name).toEqual('root'); + + expect(session.user.jwt).toEqual('myToken'); + + expect(testHostComponent.loggedIn).toBeTruthy(); + + }); + }); + + describe('Logout', () => { + beforeEach(() => { + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + (dspConnSpy.v2.auth as jasmine.SpyObj).logout.and.callFake( + () => { + const response: LogoutResponse = new LogoutResponse(); + + response.status = 0; + + return of(ApiResponseData.fromAjaxResponse({ response } as AjaxResponse)); + } + ); + }); + + it('should log the user out', () => { + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + + // mock session directly instead of calling the login method + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + expect(JSON.parse(localStorage.getItem('session')).user.name).toEqual('username'); + + testHostComponent.loginFormComponent.logout(); + + expect(dspConnSpy.v2.auth.logout).toHaveBeenCalledTimes(1); + + expect(JSON.parse(localStorage.getItem('session'))).toBeNull(); + + expect(testHostComponent.loggedIn).toBeFalsy(); + + }); + }); +}); diff --git a/src/app/main/action/login-form/login-form.component.ts b/src/app/main/action/login-form/login-form.component.ts new file mode 100644 index 0000000000..0976402b26 --- /dev/null +++ b/src/app/main/action/login-form/login-form.component.ts @@ -0,0 +1,201 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ApiResponseData, ApiResponseError, KnoraApiConnection, LoginResponse, LogoutResponse } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens'; +import { NotificationService } from '../../services/notification.service'; +import { Session, SessionService } from '../../services/session.service'; + +@Component({ + selector: 'app-login-form', + templateUrl: './login-form.component.html', + styleUrls: ['./login-form.component.scss'] +}) +export class LoginFormComponent implements OnInit { + + /** + * navigate to the defined url (or path) after successful login + * + * @param navigate + */ + @Input() navigate?: string; + + /** + * set your theme color here, + * it will be used in the progress-indicator and the buttons + * + * @param color + */ + @Input() color?: string; + + /** + * set whether or not you want icons to display in the input fields + * + * @param icons + */ + @Input() icons?: boolean; + + /** + * emits true when the login process was successful and false in case of error + * + * @param loginSuccess + * + */ + @Output() loginSuccess: EventEmitter = new EventEmitter(); + + /** + * emits true when the logout process was successful and false in case of error + * + * @param logoutSuccess + * + */ + @Output() logoutSuccess: EventEmitter = new EventEmitter(); + + // is there already a valid session? + session: Session; + + // form + form: FormGroup; + + // show progress indicator + loading = false; + + isError: boolean; + + // specific error messages + loginFailed = false; + loginErrorServer = false; + + // labels for the login form + // todo: should be handled by translation service (i18n) + formLabel = { + title: 'Login here', + name: 'Username', + pw: 'Password', + submit: 'Login', + retry: 'Retry', + logout: 'LOGOUT', + remember: 'Remember me', + forgotPassword: 'Forgot password?', + error: { + failed: 'Password or username is wrong', + server: 'An error has occurred when connecting to the server. Try again later or contact the DaSCH support.' + } + }; + + // error definitions for the following form fields + formErrors = { + username: '', + password: '' + }; + + // error messages for the form fields defined in formErrors + validationMessages = { + username: { + required: 'user name is required.' + }, + password: { + required: 'password is required' + } + }; + + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _notification: NotificationService, + private _sessionService: SessionService, + private _fb: FormBuilder + ) { } + + ngOnInit() { + // if session is valid (a user is logged-in) show a message, otherwise build the form + this._sessionService.isSessionValid().subscribe( + result => { + // returns a result if session is still valid + if (result) { + this.session = JSON.parse(localStorage.getItem('session')); + } else { + // session is invalid, build the login form + this.buildLoginForm(); + } + } + ); + } + + buildLoginForm(): void { + this.form = this._fb.group({ + username: ['', Validators.required], + password: ['', Validators.required] + }); + } + + /** + * @ignore + * + * Login and set session + */ + login() { + + this.loading = true; + this.isError = false; + + // grab values from form + const identifier = this.form.get('username').value; + const password = this.form.get('password').value; + + const identifierType: 'iri' | 'email' | 'username' = (identifier.indexOf('@') > -1 ? 'email' : 'username'); + + this._dspApiConnection.v2.auth.login(identifierType, identifier, password).subscribe( + (response: ApiResponseData) => { + this._sessionService.setSession(response.body.token, identifier, identifierType).subscribe( + () => { + this.session = this._sessionService.getSession(); + this.loginSuccess.emit(true); + this.loading = false; + } + ); + }, + (error: ApiResponseError) => { + // error handling + this.loginFailed = (error.status === 401 || error.status === 404); + this.loginErrorServer = (error.status === 0 || error.status >= 500 && error.status < 600); + + if (this.loginErrorServer) { + this._notification.openSnackBar(error); + } + + this.loginSuccess.emit(false); + this.isError = true; + + this.loading = false; + } + ); + } + + /** + * @ignore + * + * Logout and destroy session + * + */ + logout() { + this.loading = true; + + this._dspApiConnection.v2.auth.logout().subscribe( + (response: ApiResponseData) => { + this.logoutSuccess.emit(true); + this._sessionService.destroySession(); + this.loading = false; + this.buildLoginForm(); + this.session = undefined; + this.form.get('password').setValue(''); + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.logoutSuccess.emit(false); + this.loading = false; + } + ); + + } + +} diff --git a/src/app/main/action/message/message.component.html b/src/app/main/action/message/message.component.html new file mode 100644 index 0000000000..7f9a41cd87 --- /dev/null +++ b/src/app/main/action/message/message.component.html @@ -0,0 +1,61 @@ + + + + {{message?.type | uppercase }} {{message?.status}} | {{message?.statusMsg}} + + + + + + + + + → {{message?.route}} + + + + + + +

{{apiError.error.toString()}}

+
+
+ + +

{{links.title}}

+ + {{item.icon}} +

{{item.label}}

+
+
+ +
+ + + +

{{message.footnote}}

+ + +

+ Please come back in a few minutes and try to reload the page. +

+ + + + mail_outline Contact the support team + +
+ +
+ + + +
+ + {{message?.statusText}} + + +
+ +
diff --git a/src/app/main/action/message/message.component.scss b/src/app/main/action/message/message.component.scss new file mode 100644 index 0000000000..f45b4cd4c8 --- /dev/null +++ b/src/app/main/action/message/message.component.scss @@ -0,0 +1,110 @@ +@import '../../../../assets/style/responsive'; + +$accent: rgba(255, 196, 0, 1); +$primary: rgba(0, 105, 92, 1); +$warn: rgb(244, 67, 54); + +.app-panel { + display: flex; + box-sizing: border-box; + flex-direction: row; + white-space: nowrap; +} + +.fill-remaining-space { + flex-basis: auto; + flex-grow: 1; + flex-shrink: 1; +} + +.app-error { + background-color: rgba($warn, .5); +} + +.app-warning { + background-color: rgba($accent, .5); +} + +.app-note, +.app-hint { + background-color: $primary; + color: #fff; +} + +.app-error, +.app-warning, +.app-note, +.app-hint { + margin: 12px auto; + max-width: 640px; + + .message-subtitle { + padding-bottom: 12px; + + .left { + float: left; + left: 16px; + position: absolute; + text-align: left; + } + + .right { + float: right; + right: 16px; + position: absolute; + text-align: right; + + } + } + + .message-title { + padding-top: 12px; + + } + + .message-content { + margin-bottom: 48px; + margin-top: 48px; + + .link { + cursor: pointer; + } + } + + .message-footnote { + padding: 24px; + + .bolder { + font-weight: bolder; + } + + .action { + margin: 48px auto 0 auto; + display: table; + } + } +} + +.app-short-message { + + .app-short-message-text { + font-weight: bolder; + text-align: center; + } + +} + + +// mobile device: phone +@media (max-width: map-get($grid-breakpoints, phone)) { + + .app-panel { + white-space: normal; + } + + .app-short-message { + .app-short-message-text { + text-align: left; + } + } +} diff --git a/src/app/main/action/message/message.component.spec.ts b/src/app/main/action/message/message.component.spec.ts new file mode 100644 index 0000000000..699956e37e --- /dev/null +++ b/src/app/main/action/message/message.component.spec.ts @@ -0,0 +1,218 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ApiResponseError } from '@dasch-swiss/dsp-js'; +import { StatusMsg } from 'src/assets/http/statusMsg'; +import { AppMessageData, MessageComponent } from './message.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class ShortMessageTestHostComponent implements OnInit { + + @ViewChild('message', { static: false }) messageComponent: MessageComponent; + + shortMessage: AppMessageData = { + status: 200, + statusMsg: 'Success', + statusText: 'You just updated the user profile.', + type: 'Note', + footnote: 'Close it' + }; + + size = 'short'; + + constructor() { + } + + ngOnInit() { } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: '' +}) +class LongMessageTestHostComponent implements OnInit { + + @ViewChild('message', { static: false }) messageComponent: MessageComponent; + + errorMessage: ApiResponseError = { + status: 403, + url: 'http://0.0.0.0:3333/admin/projects/shortcode/001/members', + method: 'Http failure response for http://0.0.0.0:3333/admin/projects/shortcode/001/members: 400 Bad Request', + error: 'error message' + }; + + constructor() { + } + + ngOnInit() { } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: '' +}) +class ShortMessageWithDurationTestHostComponent implements OnInit { + + @ViewChild('message', { static: false }) messageComponent: MessageComponent; + + shortMessage: AppMessageData = { + status: 200, + statusMsg: 'Success', + statusText: 'You just updated the user profile.', + type: 'Note', + footnote: 'Close it' + }; + + size = 'short'; + + constructor() { + } + + ngOnInit() { } +} + +describe('MessageComponent', () => { + let shortMsgTestHostComponent: ShortMessageTestHostComponent; + let shortMsgTestHostFixture: ComponentFixture; + + let longMsgTestHostComponent: LongMessageTestHostComponent; + let longMsgTestHostFixture: ComponentFixture; + + let shortMsgDurationTestHostComponent: ShortMessageWithDurationTestHostComponent; + let shortMsgDurationTestHostFixture: ComponentFixture; + + let status: StatusMsg; + let apiResonseError: ApiResponseError; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MatCardModule, + MatIconModule, + MatListModule, + RouterTestingModule + ], + providers: [ + StatusMsg, + ApiResponseError + ], + declarations: [ + MessageComponent, + ShortMessageTestHostComponent, + LongMessageTestHostComponent, + ShortMessageWithDurationTestHostComponent + ] + }).compileComponents(); + + status = TestBed.inject(StatusMsg); + apiResonseError = TestBed.inject(ApiResponseError); + + })); + + describe('display a short message', () => { + beforeEach(() => { + shortMsgTestHostFixture = TestBed.createComponent(ShortMessageTestHostComponent); + shortMsgTestHostComponent = shortMsgTestHostFixture.componentInstance; + shortMsgTestHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(shortMsgTestHostComponent.messageComponent).toBeTruthy(); + }); + + it('should display a short message', () => { + expect(shortMsgTestHostComponent.messageComponent).toBeTruthy(); + expect(shortMsgTestHostComponent.messageComponent.message.status).toEqual(200); + expect(shortMsgTestHostComponent.messageComponent.message.statusMsg).toEqual('Success'); + + const hostCompDe = shortMsgTestHostFixture.debugElement; + + const messageEl = hostCompDe.query(By.directive(MessageComponent)); + + const spanShortMessageElement = messageEl.query(By.css('.app-short-message-text')); + + expect(spanShortMessageElement.nativeElement.innerText).toEqual('You just updated the user profile.'); + + }); + + }); + + describe('display a long message', () => { + beforeEach(() => { + longMsgTestHostFixture = TestBed.createComponent(LongMessageTestHostComponent); + longMsgTestHostComponent = longMsgTestHostFixture.componentInstance; + longMsgTestHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(longMsgTestHostComponent.messageComponent).toBeTruthy(); + }); + + it('should display a long message', () => { + expect(longMsgTestHostComponent.messageComponent).toBeTruthy(); + + expect(longMsgTestHostComponent.messageComponent.message.status).toEqual(403); + expect(longMsgTestHostComponent.messageComponent.message.statusMsg).toEqual('Forbidden'); + expect(longMsgTestHostComponent.messageComponent.message.statusText).toEqual( + 'The request was a legal request, but the server is refusing to respond to it'); + + const hostCompDe = longMsgTestHostFixture.debugElement; + + const messageEl = hostCompDe.query(By.directive(MessageComponent)); + + const messageSubtitleElement = messageEl.query(By.css('.message-subtitle .left')); + + expect(messageSubtitleElement.nativeElement.innerText).toEqual('ERROR 403 | Forbidden'); + + const messageTitleElement = messageEl.query(By.css('.message-title')); + + expect(messageTitleElement.nativeElement.innerText).toEqual( + 'The request was a legal request, but the server is refusing to respond to it'); + + }); + }); + + describe('display a short message with a duration of 2 seconds', () => { + beforeEach(() => { + shortMsgDurationTestHostFixture = TestBed.createComponent(ShortMessageWithDurationTestHostComponent); + shortMsgDurationTestHostComponent = shortMsgDurationTestHostFixture.componentInstance; + shortMsgDurationTestHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(shortMsgDurationTestHostComponent.messageComponent).toBeTruthy(); + }); + + it('should display a short message', fakeAsync(() => { + expect(shortMsgDurationTestHostComponent.messageComponent).toBeTruthy(); + expect(shortMsgDurationTestHostComponent.messageComponent.message.status).toEqual(200); + expect(shortMsgDurationTestHostComponent.messageComponent.message.statusMsg).toEqual('Success'); + + const hostCompDe = shortMsgDurationTestHostFixture.debugElement; + + const messageEl = hostCompDe.query(By.directive(MessageComponent)); + + const spanShortMessageElement = messageEl.query(By.css('.app-short-message-text')); + + expect(spanShortMessageElement.nativeElement.innerText).toEqual('You just updated the user profile.'); + + shortMsgDurationTestHostFixture.whenStable().then(() => { + expect(shortMsgDurationTestHostComponent.messageComponent.disable).toBeTruthy(); + }); + })); + }); +}); diff --git a/src/app/main/action/message/message.component.ts b/src/app/main/action/message/message.component.ts new file mode 100644 index 0000000000..90083f5c5c --- /dev/null +++ b/src/app/main/action/message/message.component.ts @@ -0,0 +1,233 @@ +import { Location } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiResponseError } from '@dasch-swiss/dsp-js'; +import { StatusMsg } from 'src/assets/http/statusMsg'; + +/** + * @ignore + * Data type for messages + */ +export class AppMessageData { + status: number; + statusMsg?: string; + statusText?: string; + type?: string; + route?: string; + footnote?: string; + errorInfo?: string; + url?: string; +} + +/** + * @deprecated Will be replaced by notification service with material snackbar + */ +@Component({ + selector: 'app-message', + templateUrl: './message.component.html', + styleUrls: ['./message.component.scss'] +}) +export class MessageComponent implements OnInit { + + /** + * message type: AppMessageData + * + * @param message This type needs at least a status number (0-599). + * In this case, or if type is ApiResponseError, it takes the default status messages + * from the list of HTTP status codes (https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) + */ + @Input() message: AppMessageData = new AppMessageData(); + + /** + * message type: ApiResponseError + * @param apiError + */ + @Input() apiError?: ApiResponseError; + + /** + * size of the message: long, medium or short? + * @param size Default size is 'long' + */ + @Input() size: 'short' | 'medium' | 'long' = 'long'; + + /** + * @deprecated + * @param short Show short message only + * A small message box to notify the user an event has occured. + */ + @Input() short = (this.size === 'short'); + + /** + * @deprecated + * @param medium Show medium message + * A message box without footnote or links. + */ + @Input() medium = (this.size === 'medium'); + + /** + * @param duration How long should the message be displayed + */ + @Input() duration?: number; + + statusMsg: any; + + isLoading = true; + + showLinks = false; + + // disable message + disable = false; + + /** + * @ignore + * default link list, which will be used in message content to give a user some possibilities + * what he can do in the case of an error + * + */ + links: any = { + title: 'You have the following possibilities now', + list: [ + { + label: 'go to the start page', + route: '/', + icon: 'keyboard_arrow_right' + }, + { + label: 'try to login', + route: '/login', + icon: 'keyboard_arrow_right' + }, + { + label: 'go back', + route: '<--', + icon: 'keyboard_arrow_left' + } + ] + }; + + constructor( + private _router: Router, + private _location: Location, + private _activatedRoute: ActivatedRoute, + private _status: StatusMsg + ) { } + + ngOnInit() { + // temporary solution as long we have to support the deprecated inputs "short" and "medium" + if (this.short || this.medium) { + this.size = (this.short ? 'short' : 'medium'); + } + + + if (this.apiError) { + this.message.status = this.apiError.status; + } + + this.statusMsg = this._status.default; + + if (!this.message) { + this._activatedRoute.data.subscribe((data: any) => { + this.message.status = data.status; + }); + } + + this.message = this.setMessage(this.message); + this.isLoading = false; + if (this.duration) { + setTimeout(() => this.disable = true, this.duration); + } + } + + setMessage(msg: AppMessageData) { + const tmpMsg: AppMessageData = {} as AppMessageData; + + const s: number = msg.status === 0 ? 503 : msg.status; + + tmpMsg.status = s; + tmpMsg.route = msg.route; + tmpMsg.statusMsg = msg.statusMsg; + tmpMsg.statusText = msg.statusText; + tmpMsg.route = msg.route; + tmpMsg.footnote = msg.footnote; + + switch (true) { + case s > 0 && s < 300: + // the message is a note + tmpMsg.type = 'note'; + tmpMsg.statusMsg = + msg.statusMsg !== undefined + ? msg.statusMsg + : this.statusMsg[s].message; + tmpMsg.statusText = + msg.statusText !== undefined + ? msg.statusText + : this.statusMsg[s].description; + // console.log('the message is a note'); + break; + case s >= 300 && s < 400: + // the message is a warning + tmpMsg.type = 'warning'; + tmpMsg.statusMsg = + msg.statusMsg !== undefined + ? msg.statusMsg + : this.statusMsg[s].message; + tmpMsg.statusText = + msg.statusText !== undefined + ? msg.statusText + : this.statusMsg[s].description; + // console.log('the message is a warning'); + + break; + case s >= 400 && s < 500: + // the message is a client side (app) error + // console.error('the message is a client side (app) error', s); + tmpMsg.type = 'error'; + tmpMsg.statusMsg = + msg.statusMsg !== undefined + ? msg.statusMsg + : this.statusMsg[s].message; + tmpMsg.statusText = + msg.statusText !== undefined + ? msg.statusText + : this.statusMsg[s].description; + this.showLinks = (this.size === 'long'); + break; + case s >= 500 && s < 600: + // the message is a server side (api) error + // console.error('the message is a server side (api) error'); + tmpMsg.type = 'error'; + tmpMsg.statusMsg = + msg.statusMsg !== undefined + ? msg.statusMsg + : this.statusMsg[s].message; + tmpMsg.statusText = + msg.statusText !== undefined + ? msg.statusText + : this.statusMsg[s].description; + this.showLinks = false; + break; + default: + // no default configuration? + break; + } + + return tmpMsg; + } + + goToLocation(route: string) { + if (route === '<--') { + this._location.back(); + } else { + this._router.navigate([route]); + } + } + + closeMessage() { + this.disable = !this.disable; + } + + reload() { + window.location.reload(); + } + +} diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.html b/src/app/main/action/progress-indicator/progress-indicator.component.html new file mode 100644 index 0000000000..847cc13cc1 --- /dev/null +++ b/src/app/main/action/progress-indicator/progress-indicator.component.html @@ -0,0 +1,33 @@ + +
+ +
+
+
+ +
+ + keyboard_arrow_right + + done + + not_interested +
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.scss b/src/app/main/action/progress-indicator/progress-indicator.component.scss new file mode 100644 index 0000000000..52168ece0b --- /dev/null +++ b/src/app/main/action/progress-indicator/progress-indicator.component.scss @@ -0,0 +1,179 @@ +// -------------------------------------------------------------- +// configuration TODO: we should implement this from the app! +// -------------------------------------------------------------- + +// color definitions +$bright: rgba(249, 249, 249, 1); +$dark: rgba(41, 41, 41, 1); + +$mute: rgba(128, 128, 128, 0.8); + +$accent: rgba(255, 196, 0, 1); +$primary: rgba(0, 105, 92, 1); +$warn: rgb(244, 67, 54); + +// -------------------------------------------------------------- + +// +// two lines with three bouncing squares each +// + +.app-progress-indicator.default { + height: 56px; + margin-left: auto; + margin-right: auto; + padding: 24px 36px; + top: 60px; + width: 96px; + + &.page-center { + left: 50%; + position: absolute; + top: 39%; + transform: translate(-50%, -50%); + } + + p, + h1, + h2, + h3 { + color: #555555; + text-align: center; + } + + .line { + margin: 0 auto; + text-align: center; + width: 70px; + + > div { + animation: bounce-keyframes 1.4s infinite ease-in-out both; + background-color: $primary; + border-radius: 6px; + display: inline-block; + height: 18px; + width: 18px; + } + + .bounce1 { + animation-delay: -.32s; + } + + .bounce2 { + animation-delay: -.16s; + } + } + + @keyframes bounce-keyframes { + + 0%, 80%, 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } + } +} + +// +// submit data has a different progress indicator +// +.app-progress-indicator.submit { + height: 32px; + width: 32px; + + .on-submit { + animation: spinner-keyframes .7s linear infinite; + height: 32px; + width: 32px; + + .spinner { + border: 2px solid $primary; + border-bottom-color: transparent; + border-radius: 50%; + border-right-color: transparent; + height: 28px; + width: 28px; + } + } + + .before-submit { + color: $mute; + } + + .after-submit { + color: $primary; + } + + .submit-error { + color: $warn; + } + + @keyframes spinner-keyframes { + 0% { transform: rotate(0deg); transform: rotate(0deg); } + 100% { transform: rotate(360deg); transform: rotate(360deg); } + } + +} + +// +// loader element +// +.loading-progress-indicator { + text-align: center; + width: 100%; + + .text { + color: $primary; + font-size: 12pt; + } + + .dot { + animation: dot-keyframes 1.4s infinite ease-in-out; + background-color: $primary; + border-radius: 2px; + display: inline-block; + height: 6px; + margin: 3px 6px 2px; + width: 6px; + + &:nth-child(2) { + animation-delay: .16s; + } + + &:nth-child(3) { + animation-delay: .32s; + } + + &:nth-child(4) { + animation-delay: .48s; + } + + &:nth-child(5) { + animation-delay: .64s; + } + + &:nth-child(6) { + animation-delay: .8s; + } + } +} + +@keyframes dot-keyframes { + 0% { + opacity: .4; + transform: scale(1, 1); + } + + 50% { + opacity: 1; + transform: scale(1.2, 1.2); + } + + 100% { + opacity: .4; + transform: scale(1, 1); + } +} + diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts b/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts new file mode 100644 index 0000000000..ecf2c1343d --- /dev/null +++ b/src/app/main/action/progress-indicator/progress-indicator.component.spec.ts @@ -0,0 +1,165 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { By } from '@angular/platform-browser'; +import { ProgressIndicatorComponent } from './progress-indicator.component'; + +/** + * test host component to simulate parent component with a progress bar. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('progressIndicator', { static: false }) progressIndicatorComponent: ProgressIndicatorComponent; + + status = 0; + color = 'red'; + + constructor() { + } + + ngOnInit() { } +} + +describe('ProgressIndicatorComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MatIconModule + ], + declarations: [ + ProgressIndicatorComponent, + TestHostComponent + ] + }) + .compileComponents(); + })); + + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create', () => { + expect(testHostComponent.progressIndicatorComponent).toBeTruthy(); + }); + + it('should display a red spinner as progress indicator', () => { + expect(testHostComponent.progressIndicatorComponent).toBeTruthy(); + expect(testHostComponent.progressIndicatorComponent.color).toEqual('red'); + expect(testHostComponent.progressIndicatorComponent.status).toEqual(0); + + const hostCompDe = testHostFixture.debugElement; + + const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent)); + + const divProgressElement = progressEl.query(By.css('.app-progress-indicator')); + + const submitEl = divProgressElement.query(By.css('div')); + + const spinnerEl = submitEl.query(By.css('div')); + + expect(spinnerEl.styles.borderTopColor).toEqual('red'); + expect(spinnerEl.styles.borderLeftColor).toEqual('red'); + + }); + + it('should change the color of the progress indicator from red to blue', () => { + expect(testHostComponent.progressIndicatorComponent).toBeTruthy(); + expect(testHostComponent.progressIndicatorComponent.color).toEqual('red'); + expect(testHostComponent.progressIndicatorComponent.status).toEqual(0); + + const hostCompDe = testHostFixture.debugElement; + + const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent)); + + const divProgressElement = progressEl.query(By.css('.app-progress-indicator')); + + const submitEl = divProgressElement.query(By.css('div')); + + const spinnerEl = submitEl.query(By.css('div')); + + expect(spinnerEl.styles.borderTopColor).toEqual('red'); + expect(spinnerEl.styles.borderLeftColor).toEqual('red'); + + // change the color of the spinner + testHostComponent.progressIndicatorComponent.color = 'blue'; + + testHostFixture.detectChanges(); + + // expect the spinner to be blue + expect(spinnerEl.styles.borderTopColor).toEqual('blue'); + expect(spinnerEl.styles.borderLeftColor).toEqual('blue'); + }); + + it('should update the progress indicator according to the status value', () => { + expect(testHostComponent.progressIndicatorComponent).toBeTruthy(); + expect(testHostComponent.progressIndicatorComponent.color).toEqual('red'); + expect(testHostComponent.progressIndicatorComponent.status).toEqual(0); + + const hostCompDe = testHostFixture.debugElement; + + const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent)); + + const divProgressElement = progressEl.query(By.css('.app-progress-indicator')); + + const submitEl = divProgressElement.query(By.css('div')); + + const spinnerEl = submitEl.query(By.css('div')); + + expect(spinnerEl.attributes.class).toEqual('spinner'); + + // update status value to 1 + testHostComponent.progressIndicatorComponent.status = 1; + + testHostFixture.detectChanges(); + + const divEl = divProgressElement.query(By.css('div')); + + const matIconEl = divProgressElement.query(By.css('mat-icon')); + + // new status: done + expect(matIconEl.attributes.class).toEqual('mat-icon notranslate after-submit material-icons mat-icon-no-color'); + expect(matIconEl.nativeElement.innerText).toEqual('done'); + expect(matIconEl.styles.color).toEqual('red'); + }); + + it('should display the default progress indicator when the status is undefined', () => { + expect(testHostComponent.progressIndicatorComponent).toBeTruthy(); + expect(testHostComponent.progressIndicatorComponent.color).toEqual('red'); + expect(testHostComponent.progressIndicatorComponent.status).toEqual(0); + + // change the status to undefined + testHostComponent.progressIndicatorComponent.status = undefined; + + expect(testHostComponent.progressIndicatorComponent.status).toEqual(undefined); + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + + const progressEl = hostCompDe.query(By.directive(ProgressIndicatorComponent)); + + const divProgressElement = progressEl.query(By.css('.app-progress-indicator')); + + const lineEl = divProgressElement.query(By.css('.line')); + + const bounceEl = lineEl.query(By.css('div')); + + // expect the default progress indicator + expect(bounceEl.styles.backgroundColor).toEqual('red'); + expect(bounceEl.attributes.class).toEqual('bounce1'); + + }); +}); diff --git a/src/app/main/action/progress-indicator/progress-indicator.component.ts b/src/app/main/action/progress-indicator/progress-indicator.component.ts new file mode 100644 index 0000000000..0023a3a363 --- /dev/null +++ b/src/app/main/action/progress-indicator/progress-indicator.component.ts @@ -0,0 +1,42 @@ +import { Component, Input, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-progress-indicator', + templateUrl: './progress-indicator.component.html', + styleUrls: ['./progress-indicator.component.scss'] +}) +export class ProgressIndicatorComponent implements OnInit { + + /** + * @param status number relating to status + * + * [status] is a number and can be used when submitting form data: + * + * - not ready: -1 + * - loading: 0 + * - done: 1 + * + * - error: 400 + */ + @Input() status?: number; + + /** + * @param color Hex value or predefined color from scss + * + * Parameter to customize the appearance of the loader. + * Hexadecimal color value e.g. #00ff00 or similar color values 'red', 'green' etc. + * + * TODO: Default color should come from app settings + */ + @Input() color = '#5849a7'; + + /** + * @ignore + */ + constructor() { + } + + ngOnInit() { + } + +} diff --git a/src/app/main/action/selected-resources/selected-resources.component.html b/src/app/main/action/selected-resources/selected-resources.component.html new file mode 100644 index 0000000000..f681553a1c --- /dev/null +++ b/src/app/main/action/selected-resources/selected-resources.component.html @@ -0,0 +1,20 @@ +
+ +

Number of resources selected: {{ resCount }}

+ +

Ids of the Selected resources are:

+ + + description +
{{ id }}
+
+
+ +
+ + + + + +
+
diff --git a/src/app/main/action/selected-resources/selected-resources.component.scss b/src/app/main/action/selected-resources/selected-resources.component.scss new file mode 100644 index 0000000000..2e8c1a55d7 --- /dev/null +++ b/src/app/main/action/selected-resources/selected-resources.component.scss @@ -0,0 +1,37 @@ + +.selected-resources { + margin-top: 10px; + + .mat-list-item { + height: auto; + + .mat-icon { + font-size: 20px; + color: #673ab7; + } + + .mat-line { + white-space: normal !important; + } + + .mat-list-item { + margin-left: 0px; + } + } +} + +.res-ids-container { + margin: 8px; +} + +.res-value { + color: #673ab7; +} + +.action-buttons { +margin-top: 20px; + +.res-action { + margin: 4px; +} +} diff --git a/src/app/main/action/selected-resources/selected-resources.component.spec.ts b/src/app/main/action/selected-resources/selected-resources.component.spec.ts new file mode 100644 index 0000000000..836a9e8d69 --- /dev/null +++ b/src/app/main/action/selected-resources/selected-resources.component.spec.ts @@ -0,0 +1,62 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatListModule } from '@angular/material/list'; + +import { SelectedResourcesComponent } from './selected-resources.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'app-selected-resources-host-component', + template: ` + + ` +}) +class TestHostSelectedResourcesComponent { + + @ViewChild('selectedResources') selectedResources: SelectedResourcesComponent; + + selectedResCount = 2; + selectedRes = ['http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw', 'http://rdfh.ch/0010/H6gBWUuJSuuO-CilddsgfdQw']; + + getActionType(action: string) { + console.log(action); + } +} + +describe('SelectedResourcesComponent', () => { + let testHostComponent: TestHostSelectedResourcesComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + SelectedResourcesComponent, + TestHostSelectedResourcesComponent + ], + imports: [ + MatListModule, + MatButtonModule, + MatIconModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostSelectedResourcesComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent.selectedResources).toBeTruthy(); + }); +}); diff --git a/src/app/main/action/selected-resources/selected-resources.component.ts b/src/app/main/action/selected-resources/selected-resources.component.ts new file mode 100644 index 0000000000..3b724582d1 --- /dev/null +++ b/src/app/main/action/selected-resources/selected-resources.component.ts @@ -0,0 +1,36 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-selected-resources', + templateUrl: './selected-resources.component.html', + styleUrls: ['./selected-resources.component.scss'] +}) +export class SelectedResourcesComponent { + + // total number of resources selected + @Input() resCount: number; + + // list of selected resources ids + @Input() resIds: string[]; + + // return selected actions and other info if any + @Output() actionType: EventEmitter = new EventEmitter(); + + // actions which can be applied on selected resources + resourceAction: 'compare' | 'edit' | 'delete' | 'starred' | 'cancel'; + + constructor() { } + + // return compare action + compareResources() { + this.resourceAction = 'compare'; + this.actionType.emit(this.resourceAction); + } + + // cancel action + cancelSelection() { + this.resourceAction = 'cancel'; + this.actionType.emit(this.resourceAction); + } + +} diff --git a/src/app/main/action/sort-button/sort-button.component.html b/src/app/main/action/sort-button/sort-button.component.html new file mode 100644 index 0000000000..621e5e323f --- /dev/null +++ b/src/app/main/action/sort-button/sort-button.component.html @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/app/main/action/sort-button/sort-button.component.scss b/src/app/main/action/sort-button/sort-button.component.scss new file mode 100644 index 0000000000..d7b9d11b0b --- /dev/null +++ b/src/app/main/action/sort-button/sort-button.component.scss @@ -0,0 +1,12 @@ +.active { + background: rgba(128,128,128,.39); + font-weight: bolder; +} + +.right.sort { + float: right; + + .mat-icon { + transform: scale(-1, 1); + } +} diff --git a/src/app/main/action/sort-button/sort-button.component.spec.ts b/src/app/main/action/sort-button/sort-button.component.spec.ts new file mode 100644 index 0000000000..a782af9aea --- /dev/null +++ b/src/app/main/action/sort-button/sort-button.component.spec.ts @@ -0,0 +1,168 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SortButtonComponent } from './sort-button.component'; + +import { Component, DebugElement, OnInit, ViewChild, } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { SortingService } from '../../services/sorting.service'; + +/** + * test host component to simulate parent component with a progress bar. + */ +@Component({ + template: ` + + +
    +
  • + {{item.firstname}} + {{item.lastname}} + by + {{item.creator}} +
  • +
+ ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('sortButton', { static: false }) sortButtonComponent: SortButtonComponent; + + sortingService: SortingService = new SortingService(); + + sortProps: any = [{ + key: 'firstname', + label: 'First name' + }, + { + key: 'lastname', + label: 'Last name' + }, + { + key: 'creator', + label: 'Creator' + } + ]; + position = 'left'; + + list = [{ + firstname: 'a', + lastname: 'z', + creator: 'André Franquin' + + }, + { + firstname: 'b', + lastname: 'y', + creator: 'Walt Disney' + + }, + { + firstname: 'c', + lastname: 'x', + creator: 'William Shakespeare' + + }, + { + firstname: 'd', + lastname: 'w', + creator: 'Charles M. Schulz' + + } + ]; + + constructor() { + } + + ngOnInit() { } + + sortList(key) { + this.list = this.sortingService.keySortByAlphabetical(this.list, key); + } +} + +describe('SortButtonComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + const listData = [ + { firstname: 'a', lastname: 'z', creator: 'André Franquin' }, + { firstname: 'b', lastname: 'y', creator: 'Walt Disney' }, + { firstname: 'c', lastname: 'x', creator: 'William Shakespeare' }, + { firstname: 'd', lastname: 'w', creator: 'Charles M. Schulz' }, + ]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MatIconModule, + MatMenuModule, + BrowserAnimationsModule + ], + declarations: [ + SortButtonComponent, + TestHostComponent, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create an instance', () => { + expect(testHostComponent.sortButtonComponent).toBeTruthy(); + }); + + it('should sort the list by lastname', () => { + expect(testHostComponent.sortButtonComponent).toBeTruthy(); + expect(testHostComponent.list).toEqual(listData); + + const hostCompDe = testHostFixture.debugElement; + + const spanEl: DebugElement = hostCompDe.query(By.css('span')); + + // expect the button position to be 'right' + // expect(spanEl.properties).toEqual({ 'className': 'left' }); + + const sortSelectionBtnEl: DebugElement = spanEl.query(By.css('button')); + + const matIconEl: DebugElement = sortSelectionBtnEl.query(By.css('mat-icon')); + + // expect that the button label is 'sort' + expect(matIconEl.nativeElement.innerText).toEqual('sort'); + + // click on the sort button to trigger the sort selection menu + sortSelectionBtnEl.triggerEventHandler('click', null); + + const matMenuEl = spanEl.query(By.css('mat-menu')); + + const sortSelectionEl = matMenuEl.references.sortSelection; + + // expect that items's names of the sort list are 'Firstname', 'Last name' and 'Creator' + expect(sortSelectionEl.items._results[0]._elementRef.nativeElement.innerText).toEqual('First name'); + expect(sortSelectionEl.items._results[1]._elementRef.nativeElement.innerText).toEqual('Last name'); + expect(sortSelectionEl.items._results[2]._elementRef.nativeElement.innerText).toEqual('Creator'); + + // sort by 'lastname' through a click event + const item2 = sortSelectionEl.items._results[1]._elementRef.nativeElement; + item2.click(); + testHostFixture.detectChanges(); + + + const listEl: DebugElement = hostCompDe.query(By.css('.list')); + const children = listEl.nativeNode.children; + expect(children[0].innerText).toEqual('d w by Charles M. Schulz'); + expect(children[1].innerText).toEqual('c x by William Shakespeare'); + expect(children[2].innerText).toEqual('b y by Walt Disney'); + expect(children[3].innerText).toEqual('a z by André Franquin'); + }); +}); + + diff --git a/src/app/main/action/sort-button/sort-button.component.ts b/src/app/main/action/sort-button/sort-button.component.ts new file mode 100644 index 0000000000..7dc60a0758 --- /dev/null +++ b/src/app/main/action/sort-button/sort-button.component.ts @@ -0,0 +1,80 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +export interface SortProp { + key: string; + label: string; +} + +/** + * a component with a list of properties to sort a list by one of them. + * It can be used together with the DspSortBy pipe. + */ +@Component({ + selector: 'app-sort-button', + templateUrl: './sort-button.component.html', + styleUrls: ['./sort-button.component.scss'] +}) +export class SortButtonComponent implements OnInit { + + /** + * @param sortProps[] + * An array of SortProp objects for the selection menu: + * SortProp: { key: string, label: string } + */ + @Input() sortProps: SortProp[]; + + /** + * @param position string + * Optional position of the sort menu: right or left + * e.g. [position='left'] + */ + @Input() position: 'left' | 'right' = 'left'; + + /** + * @param icon + * Default icon is "sort" from material design. + * But you can replace it with another one + * e.g. sort_by_alpha + */ + @Input() icon = 'sort'; + + + /** + * @param activeKey + * Optional parameter: selected sort property key + * By default it takes the first key from sortProps + */ + @Input() activeKey?: string; + + /** + * @emits {string} sortKeyChange + * + * EventEmitter when a user selected a sort property; + * This is the selected key + */ + @Output() sortKeyChange: EventEmitter = new EventEmitter(); + + menuXPos: 'before' | 'after' = 'after'; + + constructor() { + } + + ngOnInit() { + if (this.position === 'right') { + this.menuXPos = 'before'; + } + + this.sortBy(this.activeKey); + } + + /** + * @ignore + * + * @param key a string to sort by + */ + sortBy(key: string) { + this.activeKey = (key ? key : this.sortProps[0].key); + this.sortKeyChange.emit(this.activeKey); + } + +} diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.html b/src/app/main/action/string-literal-input/string-literal-input.component.html new file mode 100644 index 0000000000..3f9ee7e7e7 --- /dev/null +++ b/src/app/main/action/string-literal-input/string-literal-input.component.html @@ -0,0 +1,79 @@ +
+ + + + + + + + + + + + + + + + + + +
+ + + + {{lang}} + + + + + + +
+ +
diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.scss b/src/app/main/action/string-literal-input/string-literal-input.component.scss new file mode 100644 index 0000000000..6176307811 --- /dev/null +++ b/src/app/main/action/string-literal-input/string-literal-input.component.scss @@ -0,0 +1,51 @@ +.mat-form-field { + width: 100% !important; +} + +.existing-value { + font-weight: bold; +} + +// string literal textarea: overwrite material styling !important +.string-literal { + &.short-text { + .mat-button.select-lang { + min-width: 36px !important; + padding: 0 8px !important; + border-radius: 4px 0 0 0 !important; + } + } + + &.long-text { + display: flex; + + // select language + .mat-button-toggle { + height: 36px; + width: 56px; + + .mat-button-toggle-button { + width: 50px !important; + } + } + + .mat-button-toggle-vertical { + height: calc(4 * 36px + 2px); + border-radius: 4px 0 0 4px !important; + border-right: 0px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 0px; + } + } + + .string-literal-textarea { + display: flex !important; + + textarea { + min-height: calc(4 * 36px) !important; + } + } + } +} diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts b/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts new file mode 100644 index 0000000000..66ef44854c --- /dev/null +++ b/src/app/main/action/string-literal-input/string-literal-input.component.spec.ts @@ -0,0 +1,346 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatInputHarness } from '@angular/material/input/testing'; +import { MatMenuModule } from '@angular/material/menu'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { StringLiteral } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens'; +import { SessionService } from '../../services/session.service'; +import { StringLiteralInputComponent } from './string-literal-input.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + + ` +}) +class TestHostStringLiteralInputComponent implements OnInit { + + @ViewChild('stringLiteralInputVal') stringLiteralInputComponent: StringLiteralInputComponent; + + labels: StringLiteral[]; + + language: string; + + isTextarea: boolean; + + ngOnInit() { + + this.labels = [ + { + value: 'Welt', + language: 'de' + }, + { + value: 'World', + language: 'en' + }, + { + value: 'Monde', + language: 'fr' + }, + { + value: 'Mondo', + language: 'it' + }, + ]; + + this.language = 'en'; + } +} + +describe('StringLiteralInputComponent', () => { + let testHostComponent: TestHostStringLiteralInputComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + let sessionService: SessionService; + + let sliComponentDe: DebugElement; + let sliMenuDebugElement: DebugElement; + let sliMenuNativeElement; + let langButton; + + beforeEach(waitForAsync(() => { + + // empty spy object to use in the providers for the SessionService injection + const dspConnSpy = { }; + + TestBed.configureTestingModule({ + declarations: [ + StringLiteralInputComponent, + TestHostStringLiteralInputComponent + ], + imports: [ + MatMenuModule, + MatInputModule, + MatIconModule, + MatButtonToggleModule, + MatFormFieldModule, + BrowserAnimationsModule, + ReactiveFormsModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + }, + SessionService + ] + }) + .compileComponents(); + + sessionService = TestBed.inject(SessionService); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostStringLiteralInputComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.stringLiteralInputComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + sliComponentDe = hostCompDe.query(By.directive(StringLiteralInputComponent)); + + expect(sliComponentDe).toBeTruthy(); + }); + + it('should load values and assign them to the correct language', async () => { + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' })); + + expect(await inputElement.getValue()).toEqual('World'); + + const langSelectButtonElement = await loader.getHarness(MatButtonHarness.with({ selector: '.select-lang' })); + + expect(langSelectButtonElement).toBeTruthy(); + + // open language select button + await langSelectButtonElement.click(); + + // get reference to the mat-menu + sliMenuDebugElement = sliComponentDe.query(By.css('.lang-menu')); + + // get reference to mat-menu native element in order to be able to access the buttons + sliMenuNativeElement = sliMenuDebugElement.nativeElement; + + // select 'de' button + langButton = sliMenuNativeElement.children[0].children[0]; + + // simulate a user click on the button element to switch input value to the german value + langButton.click(); + + // expect the value of the german input to equal 'Welt' + expect(await inputElement.getValue()).toEqual('Welt'); + + // select 'fr' button + langButton = sliMenuNativeElement.children[0].children[1]; + + // switch to french + langButton.click(); + + // expect the value of the french input to equal 'Monde' + expect(await inputElement.getValue()).toEqual('Monde'); + + // select 'it' button + langButton = sliMenuNativeElement.children[0].children[2]; + + // switch to italian + langButton.click(); + + // expect the value of the italian input to equal 'Mondo' + expect(await inputElement.getValue()).toEqual('Mondo'); + }); + + it('should change a value and assign it to the correct language', async () => { + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' })); + + const langSelectButtonElement = await loader.getHarness(MatButtonHarness.with({ selector: '.select-lang' })); + + expect(langSelectButtonElement).toBeTruthy(); + + // open language select button + await langSelectButtonElement.click(); + + // get reference to the mat-menu + sliMenuDebugElement = sliComponentDe.query(By.css('.lang-menu')); + + // get reference to mat-menu native element in order to be able to access the buttons + sliMenuNativeElement = sliMenuDebugElement.nativeElement; + + // select 'de' button + langButton = sliMenuNativeElement.children[0].children[0]; + + // simulate a user click on the button element to switch input value to the german value + langButton.click(); + + // expect the value of the german input to equal 'Welt' + expect(await inputElement.getValue()).toEqual('Welt'); + + // set new value for the german text + await inputElement.setValue('neue Welt'); + + // select 'fr' button + langButton = sliMenuNativeElement.children[0].children[1]; + + // switch to french + langButton.click(); + + // expect the value of the french input to equal 'Monde' + expect(await inputElement.getValue()).toEqual('Monde'); + + // select 'de' button + langButton = sliMenuNativeElement.children[0].children[0]; + + // switch back to german + langButton.click(); + + // expect the value to equal the new value given earlier + expect(await inputElement.getValue()).toEqual('neue Welt'); + + }); + + it('should switch input to a textarea and assign the values to the correct language', async () => { + testHostComponent.isTextarea = true; + + testHostFixture.detectChanges(); + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.textAreaValue' })); + + // get reference to the mat-menu + sliMenuDebugElement = sliComponentDe.query(By.css('.string-literal-select-lang')); + + // get reference to mat-menu native element in order to be able to access the buttons + sliMenuNativeElement = sliMenuDebugElement.nativeElement; + + // select 'de' button + langButton = sliMenuNativeElement.children[0]; + + // simulate a user click on the button element to switch input value to the german value + langButton.click(); + + // expect the value of the german input to equal 'Welt' + expect(await inputElement.getValue()).toEqual('Welt'); + + // select 'fr' button + langButton = sliMenuNativeElement.children[1]; + + // switch to french + langButton.click(); + + // expect the value of the french input to equal 'Monde' + expect(await inputElement.getValue()).toEqual('Monde'); + + // select 'it' button + langButton = sliMenuNativeElement.children[2]; + + // switch to italian + langButton.click(); + + // expect the value of the italian input to equal 'Mondo' + expect(await inputElement.getValue()).toEqual('Mondo'); + + // select 'en' button + langButton = sliMenuNativeElement.children[3]; + + // switch to english + langButton.click(); + + // expect the value of the english input to equal 'World' + expect(await inputElement.getValue()).toEqual('World'); + + }); + + it('should store a new value inside a textarea in the correct language', async () => { + testHostComponent.isTextarea = true; + + testHostFixture.detectChanges(); + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.textAreaValue' })); + + // get reference to the mat-menu + sliMenuDebugElement = sliComponentDe.query(By.css('.string-literal-select-lang')); + + // get reference to mat-menu native element in order to be able to access the buttons + sliMenuNativeElement = sliMenuDebugElement.nativeElement; + + // select 'en' button + langButton = sliMenuNativeElement.children[3]; + + // switch to english + langButton.click(); + + // expect the value of the english input to equal 'World' + expect(await inputElement.getValue()).toEqual('World'); + + // set new value for the german text + await inputElement.setValue('Brave New World'); + + // select 'de' button + langButton = sliMenuNativeElement.children[0]; + + // simulate a user click on the button element to switch input value to the german value + langButton.click(); + + // expect the value of the german input to equal 'Welt' + expect(await inputElement.getValue()).toEqual('Welt'); + + // select 'en' button again + langButton = sliMenuNativeElement.children[3]; + + // switch back to english + langButton.click(); + + // expect the value of the english input to equal the new value 'Brave New World' + expect(await inputElement.getValue()).toEqual('Brave New World'); + + }); + + it('should update values and assign them to the correct language when langauges object is changed', async () => { + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.inputValue' })); + + expect(await inputElement.getValue()).toEqual('World'); + + testHostComponent.labels = [ + { + value: 'Welt', + language: 'de' + }, + { + value: 'Brave New World', + language: 'en' + }, + { + value: 'Monde', + language: 'fr' + }, + { + value: 'Mondo', + language: 'it' + }, + ]; + + testHostFixture.detectChanges(); + + expect(await inputElement.getValue()).toEqual('Brave New World'); + }); +}); diff --git a/src/app/main/action/string-literal-input/string-literal-input.component.ts b/src/app/main/action/string-literal-input/string-literal-input.component.ts new file mode 100644 index 0000000000..a67b8d65f7 --- /dev/null +++ b/src/app/main/action/string-literal-input/string-literal-input.component.ts @@ -0,0 +1,296 @@ +import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { StringLiteral } from '@dasch-swiss/dsp-js'; +import { SessionService } from '../../services/session.service'; + +@Component({ + selector: 'app-string-literal-input', + templateUrl: './string-literal-input.component.html', + styleUrls: ['./string-literal-input.component.scss'] +}) +export class StringLiteralInputComponent implements OnInit, OnChanges { + + /** + * optional placeholder for the input field e.g. Label + * + * @param {string} [placeholder='Label'] + */ + @Input() placeholder = 'Label'; + + /** + * optional predefined (selected) language: en, de, it, fr, etc. + * + * @param {string} language + */ + @Input() language: string; + + /** + * optional form field input type: textarea? set to true for textarea + * otherwise it's a simple (short) input field + * + * @param {boolean} [textarea=false] + */ + @Input() textarea: boolean; + + /** + * optional form field value of type StringLiteral[] + * + * @param {StringLiteral[]} value + */ + @Input() value: StringLiteral[] = []; + + /** + * optional disable the input field in case of no right to edit the field/value + * + * @param {boolean}: [disabled=false] + */ + @Input() disabled: boolean; + + /** + * the readonly attribute specifies whether the control may be modified by the user. + * + * @param {boolean}: [readonly=false] + */ + @Input() readonly: boolean; + + /** + * returns (output) an array of StringLiteral on any change on the input field. + * + * @emits {StringLiteral[]} dataChanged + */ + @Output() dataChanged: EventEmitter = new EventEmitter(); + + /** + * returns (output) true when the field was touched. This can be used to validate data, e.g. in case a value is required + * + * @emits {boolean} touched + */ + @Output() touched: EventEmitter = new EventEmitter(); + + /** + * returns true when a user press ENTER. This can be used to submit data in the parent component. + * + * * @emits {boolean} enter + */ + @Output() enter: EventEmitter = new EventEmitter(); + + @ViewChild('textInput', { static: false }) textInput: ElementRef; + + @ViewChild('btnToSelectLanguage', { static: false }) btnToSelectLanguage: MatMenuTrigger; + + form: FormGroup; + languages: string[] = ['de', 'fr', 'it', 'en']; + + constructor(private _fb: FormBuilder, + private _sessionService: SessionService) { + + // set selected language, if it's not defined yet + if (!this.language) { + if (this._sessionService.getSession() !== null) { + // get language from the logged-in user profile data + this.language = this._sessionService.getSession().user.lang; + } else { + // get default language from browser + this.language = navigator.language.substr(0, 2); + } + } + + // does the defined language exists in our supported languages list? + if (this.languages.indexOf(this.language) === -1) { + // if not, select the first language from our list of supported languages + this.language = this.languages[0]; + } + + } + + ngOnInit() { + + // reset stringLiterals if they have empty values + this.resetValues(); + + // build the form + this.form = this._fb.group({ + text: new FormControl( + { + value: '', + disabled: this.disabled // https://stackoverflow.com/a/47521965 + } + ) + }); + // update values on form change + this.form.valueChanges.subscribe(data => this.onValueChanged()); + + // get value from stringLiterals + const val = this.getValueFromStringLiteral(this.language); + this.updateFormField(val); + } + + ngOnChanges() { + // get value from stringLiterals + const val = this.getValueFromStringLiteral(this.language); + this.updateFormField(val); + } + + /** + * @ignore + * + * emit data to parent on any change on the input field + */ + onValueChanged() { + if (!this.form) { + return; + } + + const form = this.form; + const control = form.get('text'); + this.touched.emit(control && control.dirty); + + this.updateStringLiterals(this.language, this.form.controls.text.value); + + this.dataChanged.emit(this.value); + + } + + toggleAll() { + // tODO: open/show all languages with their values + } + + /** + * @ignore + * + * Set the language after selecting; + * This updates the array of StringLiterals: adds item with the selected language if it doesn't exist + */ + setLanguage(lang: string) { + + if (this.language !== lang) { + // clean stringLIteral value for previous language, if text field is empty + this.updateStringLiterals(this.language, this.form.controls.text.value); + + this.language = lang; + // update form field value / reset in case of no value + const val = this.getValueFromStringLiteral(lang); + this.updateFormField(val); + } + } + + /** + * @ignore + * + * Switch focus to input field after selecting a language + */ + switchFocus() { + // close the menu + if (!this.textarea && this.btnToSelectLanguage && this.btnToSelectLanguage.menuOpen) { + this.btnToSelectLanguage.closeMenu(); + } + + if (!this.disabled) { + this.form.controls.text.enable(); + this.textInput.nativeElement.focus(); + } + } + + /** + * @ignore + * + * Switch focus to input field after closing the menu by clicking anywhere outside of it + */ + menuClosed() { + if (!this.disabled) { + this.form.controls.text.enable(); + this.textInput.nativeElement.focus(); + } + } + + /** + * @ignore + * + * Set the value in the input field + */ + updateFormField(value: string) { + if (!value) { + value = ''; + } + if (!this.form) { + return; + } + this.form.controls.text.setValue(value); + } + + /** + * @ignore + * + * Update the array of StringLiterals depending on value / empty value add or remove item from array. + */ + updateStringLiterals(lang: string, value?: string) { + const index = this.value.findIndex(i => i.language === lang); + + if (index > -1 && this.value[index].value.length > 0) { + // value is not empty and exists in list of stringLiterals + this.value[index].value = value; + } + + if ((!value || value.length === 0) && index > -1) { + // value is empty: delete stringLiteral item for this language + this.value.splice(index, 1); + } + + if (index < 0 && value) { + // value doesn't exist in stringLiterals: add one + const newValue: StringLiteral = { + value: value, + language: lang + }; + this.value.push(newValue); + } + + } + + /** + * @ignore + * + * In case of strange array of StringLiterals, this method will reset to a API-conform array. This means an array without empty values. + */ + resetValues() { + const length: number = this.value.length; + + if (length > 0) { + let index = length - 1; + while (index >= 0) { + // remove items with empty value + if (!this.value[index].value.length) { + this.value.splice(index, 1); + } + index--; + } + + // does an item for selected lanuage exists + if (this.value.findIndex(i => i.language === this.language) === -1) { + this.language = this.value[0].language; + } + + } else { + this.value = []; + } + } + + /** + * @ignore + * + * Get the value from array of StringLiterals for the selected language + */ + getValueFromStringLiteral(lang: string): string { + // get index for this language + const index = this.value.findIndex(i => i.language === lang); + + if (this.value[index] && this.value[index].value.length > 0) { + return this.value[index].value; + } else { + return undefined; + } + + } + +} diff --git a/src/app/main/directive/admin-image/admin-image.config.ts b/src/app/main/directive/admin-image/admin-image.config.ts new file mode 100644 index 0000000000..d0bb349f0e --- /dev/null +++ b/src/app/main/directive/admin-image/admin-image.config.ts @@ -0,0 +1,21 @@ +/* eslint-disable max-len */ +/** + * @ignore + */ +export class AdminImageConfig { + + /** + * default-project-logo + */ + public static defaultProject = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAuUlEQVR4Ae2XP8rCUBAHp5F4gPxBsA45mpUgXkt4Se4Rkc97fIQkhVZrK+JbxGwhujN9Bh77K8IPsWTPkSsXOnYkGLPmjNx5YoUhCX/Igx0LzNgiT9zwBhU1AxLxQEpGQCJOtFT653tEMQUgRxR7LVEjqhkABaLaEGVAVAM5BQ2iOhJFjPSAXeBVPKADfqa+Aw/4Dr53Bx6wD/iZfkZgQgwcidIiBgb0H5CZ/lOClmgYZzxOoMRxjLkBL3E6cltSSnYAAAAASUVORK5CYII='; + + /** + * default-user-avatar icon + */ + public static defaultUser = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAA+klEQVR4Ae3SMUrDYBjG8X+n1kPoKg4l5g6Cu7jokaxbW5KhNxAcdZMiiOgB2iaXMChKO5jHrEr7Ncn7OSjf77/nScJLEAQNxKTkrKoyEiK82mGCvlWS0vP3+Hu0pqmviQnaUIIHMdpYSYRZihyNMcuRozlmK+Ro+QcGMuRohlmCHA0xiygdZ9qH3zzUEV70mKI13dEFXxMp5Y+fM6KLVxFj5iyrZgzpE/wre5xzyS0LCj6rChbcMOCMXYxiBuTIUcYFh7TQ4ZRnVLMnTujQwAGPqGEP7FPTMW+oRa8c1Xv7D9Sy9zpfcY0MXbFVgQy9sJWMNR8IA0EQfAFx/QsJxgdnsQAAAABJRU5ErkJggg=='; + + /** + * default "not found" image in case of 404 error + */ + public static defaultNotFound = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYAAADL1t+KAAAD8GlDQ1BJQ0MgUHJvZmlsZQAAOI2NVd1v21QUP4lvXKQWP6Cxjg4Vi69VU1u5GxqtxgZJk6XpQhq5zdgqpMl1bhpT1za2021Vn/YCbwz4A4CyBx6QeEIaDMT2su0BtElTQRXVJKQ9dNpAaJP2gqpwrq9Tu13GuJGvfznndz7v0TVAx1ea45hJGWDe8l01n5GPn5iWO1YhCc9BJ/RAp6Z7TrpcLgIuxoVH1sNfIcHeNwfa6/9zdVappwMknkJsVz19HvFpgJSpO64PIN5G+fAp30Hc8TziHS4miFhheJbjLMMzHB8POFPqKGKWi6TXtSriJcT9MzH5bAzzHIK1I08t6hq6zHpRdu2aYdJYuk9Q/881bzZa8Xrx6fLmJo/iu4/VXnfH1BB/rmu5ScQvI77m+BkmfxXxvcZcJY14L0DymZp7pML5yTcW61PvIN6JuGr4halQvmjNlCa4bXJ5zj6qhpxrujeKPYMXEd+q00KR5yNAlWZzrF+Ie+uNsdC/MO4tTOZafhbroyXuR3Df08bLiHsQf+ja6gTPWVimZl7l/oUrjl8OcxDWLbNU5D6JRL2gxkDu16fGuC054OMhclsyXTOOFEL+kmMGs4i5kfNuQ62EnBuam8tzP+Q+tSqhz9SuqpZlvR1EfBiOJTSgYMMM7jpYsAEyqJCHDL4dcFFTAwNMlFDUUpQYiadhDmXteeWAw3HEmA2s15k1RmnP4RHuhBybdBOF7MfnICmSQ2SYjIBM3iRvkcMki9IRcnDTthyLz2Ld2fTzPjTQK+Mdg8y5nkZfFO+se9LQr3/09xZr+5GcaSufeAfAww60mAPx+q8u/bAr8rFCLrx7s+vqEkw8qb+p26n11Aruq6m1iJH6PbWGv1VIY25mkNE8PkaQhxfLIF7DZXx80HD/A3l2jLclYs061xNpWCfoB6WHJTjbH0mV35Q/lRXlC+W8cndbl9t2SfhU+Fb4UfhO+F74GWThknBZ+Em4InwjXIyd1ePnY/Psg3pb1TJNu15TMKWMtFt6ScpKL0ivSMXIn9QtDUlj0h7U7N48t3i8eC0GnMC91dX2sTivgloDTgUVeEGHLTizbf5Da9JLhkhh29QOs1luMcScmBXTIIt7xRFxSBxnuJWfuAd1I7jntkyd/pgKaIwVr3MgmDo2q8x6IdB5QH162mcX7ajtnHGN2bov71OU1+U0fqqoXLD0wX5ZM005UHmySz3qLtDqILDvIL+iH6jB9y2x83ok898GOPQX3lk3Itl0A+BrD6D7tUjWh3fis58BXDigN9yF8M5PJH4B8Gr79/F/XRm8m241mw/wvur4BGDj42bzn+Vmc+NL9L8GcMn8F1kAcXgSteGGAAAACXBIWXMAAAsTAAALEwEAmpwYAABAAElEQVR4AeydB7xVxbm36Qc4wAEEpQgWqtiN3WiC3sSIEhUVRZqoiakmuTf1lsTc3JSb3HhvTMwXkijSwUaQiCkaTKzRGGMMiIAoSEd6Oxza97zHvbfrbPY+Z5c1q+3//v3WXm3KO8+sNf+ZWbNmNWumnwiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIQMgEmoccv6IXAREogcCCBQtabdy4sS1e2+zatatNy5Yt2/Brbfv79u1r07x5cztWv3/gwIE2dtyz34ztOo7VcW5fetv29+/fv69169b15w4dOlRXW1u7r7q6un5/0aJFtXfcccd+3OknAiIQQQIS9AhmikwSgYkTJ7bu0qXLEZDoisjWsK45ePBgDUJd06JFi05sdwiJ0k7i34bY1y9UBrZh33aOberZs+emoUOHSvBDyhhFKwISdF0DIhAigfvvv7/l3r17j8SEIxHF7gh2d8SyO9tdEO1Y3Z/YjOmHNrNstIU0baC3YCMt/I0jR448ECJmRS0CFUEgVgVGReSIEploAnPmzOm8Y8eOo1u1anU04t2bxPZEuFslOdEIvbXa15DeVWyvYns1Ar8tyWlW2kQgDAIS9DCoK86KIUALvGtdXV0/uqaPJ9F9QuwqjxRzxH0H4r6SZ/jLMWz56NGjt0TKQBkjAjEkIEGPYabJ5OgSQMDbYd1xPFfux7ofXc+do2ttpCzbjMC/gdAv37Nnz5sTJkyojZR1MkYEYkBAgh6DTJKJ0SYwbdq0TgjRYARpCJYeE7dn31GjC8uDVITeguciejYW0z2/M2o2yh4RiCIBCXoUc0U2RZ7ApEmTOldVVZ2A8JiI94m8wTE1EFEH8aGViPwiuudfGzNmzPaYJkVmi4BzAhJ054gVQVII3HXXXVW8SnYiInM6aZKIh5CxsH8LcX9527Zti2677bZ9IZigKEUgsgQk6JHNGhkWFQI8F+9LN/rptBRPZG2TtOgXMgFEfS8m/IP8eJlWu42c108EKp6ABL3iLwEByEWALvW2/M5AxN/HYhO86BdRAoj7BpaXmDnvb7fffrsJvX4iUJEEJOgVme1KdD4C06dP74KAn4tAWLe6WuP5QEXwOHlWS4v9r0yB+2e95x7BDJJJzglI0J0jVgRxIDBlypS+PJ89j1HVgxEF3RdxyLQ8NiLsB1kW8f7/c+PHj1+dx5kOi0DiCKjgSlyWKkHFEJg1a5a9K/5BRFyD3IoBFxO3NogOU58cNWqUrfUTgUQTkKAnOnuVuHwEaJEfx1fFhiLkffO50fHkEKDF/iaT/SwYN27cyuSkSikRgYYEJOgNeWgv4QR4Rn4MSRzKcmzCk6rk5SbwBj0yCzQyPjccHY03AQl6vPNP1hdIgFfPutNCu5TCvH+BXuQswQRosb/Ol+B+xzP2TQlOppJWYQQk6BWW4ZWWXJtbnW51e0Z+FmLeotLSr/TmJ8DzdSafO/BnhP1Pmjs+PyediQ8BCXp88kqWFkHgjjvuaDFgwIAzKbTtObl9MEU/EchJgNb6Lip7f1i6dOnLXDcHczrSQRGIAQEJegwySSYWRyA14G0YQt69OJ9yXckEqPytI/2PMiL+7UrmoLTHl4AEPb55J8uzCFj3Ol2oH0LIz8g6pV0RKIgAon6I6+fFTZs2PaFZ5wpCJkcRIiBBj1BmyJTSCfA++QmI+eWE0KH0UORTBN4lgLBvQ9h/PXr06KViIgJxISBBj0tOyc6cBO65556OfMZ0GM9BT8jpoEIOIkD7EaB9rOtsDY/6hWfDdbadPpba51DzNmy3Bo9Nb9sm1z5+WlUIvrzJhMurvB3xG95f35XXkU6IQEQISNAjkhEyo3gCtMpPQnSuQJjaFu87Xj5Sgr0VgbFlC/tb6ZGo3+7QocPW4cOH7/Y7RTaw8Oyzz269c+fOToTdFWHrStxdYd7V9tmugX3i3xwgncb2kRtvvHGx34wVngj4SUCC7idNhRUIAfsuebdu3S5DTE4LJMIAI0GoDxHdO4imfRJ0FfsbmF9+Cx8b2RmgGQVFxZiFlnv27OncqlWrrtjZlfywD9scyfbR2J+4D9uQtr/s2rXrt/oOe0GXhxyFQECCHgJ0RVk6AUSkNy3Ta1KtxNIDiohPa/0hFKtZm3ivQiBXx/2daBN6PozSE8Q2K98xpKsvaUxELwpp2UgF6yEqWDYiXj8RiBQBCXqkskPG5CNg3b+DBg26AGEYyhLbbl4EYQ9pXEL39XJrhVfCTGWWd0OGDDmSitgx5F29yLOuzpfXMTh+gArY47ze9jxr61HRTwQiQUCCHolskBGNEeC98mq6da9FBI5rzF1Uz1Ho72BZjIC/xgdhVtC6OxBVW4Oyi1Z8d1rx/WjtngyX3kHF62c8VM6WYP8c8tMqafqJQOgEJOihZ4EMaIzAzJkze3H+egr9msbcRe0cAr6JCshrLIt59cm61NWSy5NJiHtXWu+nwOpkliPyOIvkYUR9MzbPZsDc+kgaKKMqioAEvaKyO16J5ctop1FgXoGYx+L1KUR7K4RfpjdhEa22jfGiHQ1rrQKHQJq4n4RFsZhTgHzfxyOUubza9o9oUJQVlUpAgl6pOR/hdNugKlpsH0HIz4qwmRnTqHTYJzlfXMJPc4FnsJS1Yc/dBw8efBzXwMkEdAJ8q8oKMADP2PjssmXLHtc1EABsRZGTgAQ9JxYdDIvAvHnz2m/fvn0U8fcJy4ZC4qVVthch/xuC8yLdre8U4kduSiMwceLE1rxrfzrMz0c0O5cWSmC+ljM24P64v6kQGC1F5CsBCbqvOBVYOQRomdfwKcuxhNGtnHBc+kXENyIsL2zYsOHvmuvbJenDw7aeG64Pa7G/nyWy1wi2ra+trZ12yy237Dg8FToiAu4ISNDdsVXIRRCgsO7Oc8ixtMBsVrIo/pYzQv0pno2/GUXjKskmrpHms2fPHkyaL+TRjA2ajOJvC9fLVK6XzVE0TjYlk4AEPZn5GqtUMRDKutdvpPs6ct8tpzW+llb54zfccMMbsYJaIcYy/W8/BP5Crp1jo5Zkrp1dVDimjR07dm3UbJM9ySQgQU9mvsYmVYxkH0DBN5JC2T4UEqXfZgrjP1AYL8Q+vXIWpZzJYUuqUmjCPjDH6dAOce3sZZnJJDRvhWaEIq4YAhL0isnq6CV0xowZp2DVVYh5ZGZ+o/C1SWD+yIQhL9NdeiB61GRRYwSoIB7P+WEskXnGTg/Pfq6ph+jlea0x23VOBMolIEEvl6D8l0SArtJzaQF/pCTPDjxR4NayPIOQ/xkhr3MQhYIMiACvjbVimuDzqCheFJWeH0T9ENfXPET9rwFhUDQVSECCXoGZHnaS6R69hK7RC8O2Ix0/Be1LTAbzOEKuKTzTUBKwnjNnTmc+dmNf5RsUleQg7E/Q/f5UVOyRHckiIEFPVn5GOjU2WQgtJ5v57YyIGGojkR9ByDVyPSIZ4sKMqVOnDqLnxYQ9Eu+wU4F8HlH/LWuNzXCR4RUcpgS9gjM/yKQvWLCg1Zo1a64lTnvdKNSfdX/S3f9CmzZtnlD3eqhZEVjkNjlNdXX1RQj7+VQoWwYWcZ6IsOPvXIdzuf40TiMPIx0unoAEvXhm8lEkgdSEIDfgbUCRXl04fwcxf4R5t1e6CFxhRpsAAzFtsNzltNaPC9tSRH3h4sWLH9JUsWHnRHLil6AnJy8jmRIKzuYMgLuKVtGpYRpI9+ZBbHm2V69eTw4dOnR/mLYo7nAJ2DWJsF9IC3ko12WoZSA2vEj3+6PhElHsSSEQ6sWcFIhKR34CvEZ0KWfPy+/C/RlaQuspxOdScK5xH5tiiAsBBmcei6DbY6BQv+qGqD/JtflkXLjJzugSiMz7v9FFJMtKJUAryObcDlXMbQQ73Zq/kJiXmovJ9cc18RaDIn9GCpeHmUoqFR+kFysWXxYMk5PibpqAWuhNM5KLEgjQ+jmdgurKErz64oVWD1PD75/PTG9679cXoskNJPX2xUWk8ANhdcFzvdKJdOhBvty3MLmklTLXBCTorglXYPgMghvEwLMbwiocQb6NAnK2WuUVePGVkWSbZY4enWsQ1uoyginZK9fsAe6Z6aNHjw61x6DkBMhj6AQk6KFnQbIMoFA8hoJpLAVTq5BStrxTp04PDh8+fHdI8SvaGBO45557OrZt2/YaknBsGMng3uFz6nWTx48fvzqM+BVnvAnoGXq88y9S1k+aNKkHLZxRIYr500uXLp0mMY/UZRErY+wb5lxDU7iO/xSG4dw7bXiuPzr1el0YJijOGBNQCz3GmRcl02mZd8GeW1gCHzFM4buXls2v9PGLKF0R8beFGebOYErg4YhsGOXkNrr+7xkzZsz2+JNUCoIioBZ6UKQTHA/PzDtYNztJDFzMDSuCPlNinuALLKSk2YBKRHU213YY8xbUEO8Y7q12ISVf0caQgAQ9hpkWJZMpcNowmnw0rZiuYdlF3FdYpSKs+BVvcgkw6nwxqZtmvUBBp5LKxJH79u270b4eF3Tcii+eBCTo8cy3yFiNmA+n4OkZskHdGFU/XqIeci4kNHp7X52u90m0mHeGkMQ+AwcOvCyEeBVlDAlI0GOYaVExmYE79s3pk6NgD6307hL1KOREMm3gIyrrmHHwXlK3JegUco+9jzEq7ws6XsUXPwIS9PjlWSQsnjJlynEY8qFIGJMyQqIepdxIni2I+mZGoN9DS31dCKkbNm3atKNDiFdRxoiABD1GmRUVU+narqEL8jpaDpG7fiTqUblKkmkHor6ztrb2PkT9rYBT2JI4R+qxUsDUYxZd5ArkmPGrOHPtu+YM1LkeMW8f1cSnRd0mCYmqjbIrvgQmTJhQ26NHj2mkwAbMBfbjnuvEmJXrEPXQv+ceWKIVUVEEJOhF4ZLj9evXWzd7r6iTMFFv3779OLVoop5T8bTPPsHLBDQPYn2g07Qi6scwk9wH40lNVrsmIEF3TThB4TMwZwADz86JS5LSLXWJelxyLF528jrZ/k2bNs3G6kA/y8vgvPfbp1/jRUvWBkFAgh4E5QTEYaLIu7hXxS0pEvW45Vi87L399tv3Usmdzr2xKSjLuaab01IfwT2pSWeCgh6TeCToMcmosM3k2d1VFCKhfIWq3LRL1MslKP+NERg3btyuvXv3TkXUdzTmzs9zqefpw/0MU2HFn4AEPf556DwFvC5zLgVIf+cROYxAou4QroJuxkC5rVR6TdRrg8LBPTnE5psPKj7FE30CEvTo51GoFvK++ZEUUpF637xUICbqjNC/Sc/USyUof40RoKW+gXvFut/3NebOz3M8T7+M6zm0aZf9TIvCKp+ABL18hokNgUE/LXjf/KMkMEmvyWia2MReseEnjGli3+Z98QcQ9YNBWEMrvbVNvxxEXIoj+gQk6NHPo9AsHDx48NkUGImbnUrd76FdUhURMV/+W8JAublBJZZ79Dh1vQdFO9rxSNCjnT+hWTdnzpzOCN8loRngOGJ1vzsGXOHB8+nVV2ilPx4UBrreP6yJlIKiHd14JOjRzZtQLdu9e7d9Ra11qEa4j1zd7+4ZV2wMfHr1aRIfyGxy3Kttq6qqhlUsbCW8noAEXRfCYQRmzZp1Kgf7HXbCwQFaMasI9lc8dzzkIPgmg1T3e5OI5KAMAnzMZS7X+NYygijYK/GcwL17QsEe5DBxBCToicvS8hJkk1UgcpeWF0phvimA9tKyeGj06NF/Yz1Hol4YN7mKDwE+5rKHgaUPcG0fCMJq7t1h3MNtgohLcUSPgAQ9enkSqkWMmP0A4hrIh1cofH6NmNd/X5ruyb9L1EPNekXuiACivpqgf+co+AbBcg915B5+f4OD2qkYAhL0isnqphM6Y8aMbrg6u2mX5bugdf63MWPGvOoNSaLupaHtJBHgdbY/I7avBZEm7q3zJ02a1DmIuBRHtAhI0KOVH6FaQ4FzKYvza4ICZxPdkPNzJVainouKjiWBAJMa2ats9T1SLtNDz1erNm3aJGIyKJeckhi288I7idCSmCb7khrpssXpz54lIugP0Q1Zly8iiXo+MjoeZwL2HXWu/wdIQxDP00/knj4mzrxke/EEJOjFM0ucDwbR2ExwgQyEI54n6H5s8nOTEvXEXWZKEATs2ued8d8EAYOK80dstscg4lIc0SCgzI5GPoRqBV2Bp2GAPT93/XuDWbSeKzQSiXqhpOQuTgS4B15E1Be6tpnHZz379+9/out4FH50CEjQo5MXoVhCDb4V3YAXBRB5HQWMvZNb1PvmEvUAckZRBE6grq5uPveC8y+zEccH1EoPPHtDi1CCHhr6aEQ8YMCAMxhEU+PaGgqWBYxq315KPBL1UqjJT5QJ2DfUue+eCMDGbnyT4eQA4lEUESDQPAI2yISQCCxYsKDV2rVrP0fLuaNLE+heXE8vwM8ZCFfWYCBeqzuFisHVFIShXLekYSNpmUw6dpbDyyb+IA0D4W6DELuxPpJ1G9JWVO9FOTYE6JfkHdpJ2jYQ5xpGXy+65ppr1gYYf2SjspYzFepbMbCXSyO5bjdz3d5d7v3n0kaF7Q+BUApGf0xXKOUSmDZt2rkUtB8pN5ym/DP95b0UJiubclfI+TiLOryPhsX1CNzFLCdRyLaHvw1ItCXJ96JV5KjDHKzjK2RWuXue9M+rqal5fNiwYXsLyfekupk5c6aJ+cdcV1JhPpcBeS8nlaPS9S6BJBciyuNGCEycOLF1p06dPkdB0qERZ2WfQrD+Rpf5r8oOyBNA3ET92WefbbdixYqbScItvH9/PKJm0+tW5PScXA8HqMjUsd7MjGZ/gMnPEJpnPdlbcZuI+uVcD2e5TDi8t3Lt/VitdJeUww9bz9DDz4NQLKiurj4tADGvpdD+vd8JjNMz9cmTJ/dGzH9JV/N/UaiewhsFNSbmtFCbVeJC2ltyTbSDQ2+E/VqY3EsFbQIsKrZxAYc/wGGX3/eJNzz4doa7Rrx7oSRwW4KewExtKklWeNIFd25T7so9T+H9hA3+KTecXP7jIOr33Xdfv7Zt206nZTRi7969nWmZt0yLeK40VcqxNAMTdrYHIGjfnD179hfZrsjyiFbzHu7HIOZ6P69SrrFKTWdF3kCVmtnpdPMs1wZkHZHed7Res2zZspcchV0fbJRFnVm6uvC8/Ke0vC6gNdrWREy/hgSMCZW+FqyPhtNnEfUxDV1Uzh7vpr8CgxUuUwznnnTvH+syDoUdLgEJerj8Q4md1oDzmjqFx3xG8R50ncAoiDqCfRMj1zNjEf7yl7+0Jt3foZv9g7RCW5lw6ZefAD0XzRH23rj48pQpU87J7zLZZ7hWHkXUnd4zXIvO7/1k51K0UydBj3b++G7d1KlTexLosb4H3DDApbxzvqrhIXd7YYs6KevmFfXly5dXUTCfyFL/rNxdypMTMkLTAl79qWz+81133VWVnJQVnhIeT22gYvNi4T6KdwnnQQ8//LDr3rniDZMPXwhI0H3BGJ9Agmid09X8ZNBEoiDqtDTHW0udZ6I7mQlsAiL/Z1rpQaOIZXzWiwG/Knhd1K1btw/HMhH+GP0M92hZ8zU0ZQbjOZyPn2nKBp13Q0CC7oZrJEOdN29eewoL1yNdlyJoq8MAELao07rqnhb18ePHL6fFOZpuVIl6ERcDDO073tci8BU56j01m+IrRSArxempldoLUgqsOPmRoMcpt8q0ddu2badQYNokJs5+FMR/dBZ4AQFHRdQfeOCBagY6SdQLyLO0E2ulc33aY4oz6Ra2Z+oV+aN3x1rpzgZeGOPu3bsPqUi4CU+0BD3hGexNHq9Pne7d93ubFumyIJ+d57M/KqJO97tEPV8m5T/eguv0yN27d5+Z30myz9C7s4kUOv0aG6J+RrIpVmbqJOgVku82xSTdwUe5TC6CHmrr3Js2ibqXRry2eS+9FddSn3hZ7a+1tbW1T/sb4mGh9WFCnyA+mXxYxDrgjoAE3R3bqIXstHVOYt9gCs+3o5RoiXqUcqMoW1rS5VzRYjNhwoR1MFhSFLXiHbsuE4q3SD7KIlCRA0/KIhZDzzZve8eOHf+FZ5NtXZlP4XNP1AQ9ndYIzf2+a9asWceTDzZ73Dk8K02bqHWKAC3zZrTQ99CbNAtGdwQBhnw42L59+z2DBg3aceKJJ0YmU+hV60PX+C2uGHDP7oT1/2p+d1eEgw9Xgh4888BjRNBORESucxUxBcPbiPk9rsIvNFyeWfenAPwg7o/l1Zz/TT2LrPcuUS+UYiTccbkesulQtwZhDXHZZC67iG8L6zfZ/xWViWcWLVq0NojJkRpLI6J+E9f0sY25KfPc9NGjRy8tMwx5jwgBCXpEMsKlGbQKr6PF4+x1NQrCUD/N+OCDDw6mlfUJWndXUhAfRaujBYL+yJ49ez598803b0yzlainSWidTYBr2EaVH+LaOYig72VZzjKL/Zm0YN/Mdh/UPvduP+7dsQ7jexlBn+swfAUdIAEJeoCww4gq1d3+ZQonm47U9x8iuhcR/SGFXuBdlQsWLGi1Zs2aT1AY344NfZnIpYp01qexqqqK3X2/ipqoY9w7TLxzH7zU/e771ehPgNbtz3KQpY7r6a/WLU3Ij5Jne/yJobhQaKV/glZ6j+J8Fex6D9fj/5A2p5PZFGyNHJZFQIPiysIXfc81NTX9XYm5pR4xfTUMMZ8/f36ndevWfR/h/iZmDKCFnhFzs4sWOuVU66vatWt397333tvdjtkv7IFymNCNFpfNKKdX2upzJHp/VilEQFuwtEXUz2X/e+TZJ/gUbihTphL/Kw4p2adsj3EYvoIOkIAEPUDYYURFoeR0AgkKm78GnS7EvGrLli3/Tav8ZoS8K2nMaUJURR170zPK1Ys6053eaDPKUTnJmQ4dDIeACTtCbl+DO55W+r9wvU2YO3dux6Ctqa6u/kfqkYCTqEmb0zLCidEKNCcBCXpOLMk4yICeVqRkoKvU0HpZy2C4Na7CzxcuM959HhEcScuixgrdxn5xEPVrrrnmTViOJj2+TxNLuNZ9bD0p9QuFt40ir1/Sx+y8fvkJIOrNuc56we0WJry5KvU1vfwefD5z5ZVX7qAS6Ow5PmGfQFkhLfA538IITpkYBvWA4hwwYMBxFETOmn0UBIG3znmeeCEC9CnEr2tTYp7GHGVRJx326VVfut9NmGlFNrOWvi08crCu4wMc3wYrGxz4NuJkg72WsF7Beg3nN3N+p7GyD8mYP1ub6Ft4+r1LgB4UcDQfCJdRy5Yte1/QXOzRlqs4uQ6qKSsqeiIfV2yDDleCHjTxAOPjRu3vKjoKN7Ron7NCJpfd9kEJCtZPEfeRuc43diyqoo7NZT1TN+E1ETYhp9DfQp78mccQk1j+HdEezbEPMuvYhYwlsO9gX8Cx83bt2nUeYn8uHC+A53mI+oX4/wh+P4G/73PsYdy+zrLXxN0Wa81X+g92LVjOh8+Hg+56Jw9fIw/2u8oDroV+rsJWuMERsC5Z/RJKwPFNupDZrGqDRMcHJd6PgH2AArUtlZWiozZRR/yuMo8MlMu80mYD5XilzUTrasQt8GYpcdqgPRsoN5kBhst5Vcm+0jYdIc05+YyJqwk4buwVq9cQ4T9y7FnC+RsTCK0knfsZMHiQtB7s0qXLQR6L2IjtNC8vOOtKbvbNb36z+ZAhQ5rv2LGjRZ8+fVosXLiwVb9+/aoJZxAifxZhX4Dn86kE9DD3iFqzUvinDYjr2tLM9VcD+4t27tz5JOn4U1BpsXuNa9RmjnPyvDtVVvwhqPQoHjcEMne5m+AValgEpk2b1omb9J9dxU/hPg3xWeYq/Fzh0t3+AwrUTyBkHcoRFIQOnYreK20I50bSZ6Ke85U2ztV3hWP7BvL2MZY5iPjzCMx2BgnuX7t27QGeheYeIZgLaBPHCKsFQt9q48aNbXr27NmLisMVcP8o8Z6FLe3JB+vSbyKUZJ22yhS/NVRqvkWF6xfkVWCve1HRO4F4r3dBlHTZO/jfJz2hvJrnIk2VGKZa6AnNdW7Ofq4KWwr0fbSW3woSnb2mtnnz5guIu6TWudfWuLXU27Zte47lJYX5G6zvRdAfpAv9bWYy24foOuuGTVUObH4BW5ay/5NTTz31Xrrs7RnyOETgo1QmOleSsFtFksUmLzoRDtazso4lkN/ixYuXDhw4sJb4fZ/CmeuqOWk6joQsCiQxisQJAT0Yc4I1/EC56Y93ZQVhvzF06FBnQpLL7q1bt9qgnb4UPL5UQk3U+UXuPXXS1+CVNiowo2kZP4KYfw3uQ9evX//DcePGLbWWlEsxz5EHh4iv7uqrr97K8+MnEbPPwHA49v4SG7fAsiIG0ZEH9rgD7WvZm0qV068XZueB5TesnQkuFTM9R8+GHrN9CXrMMqwQcyl07FGKM0GnMLNneYH+KES70SL0dba7uIh6hw4dRpH2O3kWvur222/fS6HufQ4eaD5YZA888MCBW265Zce8efOe41nyPyPqV3PNzUt12wZuTxgRkt6OpDvwd9LpnXE2EJU0OSszwsijSoxTgp7AXKfA7cbNWe0qabTOAv+YA62HbrQCfb9e4yDqw4cP302L3Lq9QxXy7OspLexz5sx5mryZQC/CJ3CzuEJa6x1Iqy2B/pYvX76CCt1eR5F2YWBmjaOwFWwABHwvIAOwWVE0QYCC9egmnJRzeo21zsoJoBS/pMmm4XQyiDMl6tfxrHoSBVpkpon1dL8HLhzF5JEJ+4gRIzbxtsAvaLF/hMrkJFrriX7VjTS2gZGvPUaFMKfb3d5sWFmI2xLd9C7Rn7xFgIAEPQKZ4LcJ3PDOBJ2COvDuduODljttnSLq9r715fQESNRLvCAtj2699dYVsPwsQXyW/Xd4VFJiaNH25vp6bCz1xL2isfPlnKMS6azsKMcu+S2MgAS9ME6xcsUN77KWHYqgB5EBEnV/KDNobxfT807kee91VC7/YRPT6OcfAZeC7rIx4B8BhZSPgAQ9H5mYHqfL2EpPJ6NvKUh23HDDDWtjiqYgsyXqBWFq0tFtt922j/f9n8LhKHo9npOoN4msYAcMSl3DvbivYA9FOETQe1KGtCzCi5xGiIAEPUKZ4YcpPGvuRbeZk2fNdLfbR0Scdn37waDcMCTq5RJ81z8D+Q5cf/31C7lmxiLqv7HBcvqVT8C4Irxvlx/S4SEQbmuu/6KnVj48JB0Jg4AEPQzqDuNEdJ11t1MoB/5lNYeoGg1aot4onoJPWgWQXp3ltNA/RmXzMbXUC0bXqEO4OnuOTth6jt4o/eieTOaIlejydm4ZrXNntWu6+lY7T0CEIjBRp9v4ciZ2sYFyE2gZ2RfLmkVs7vf6L6Xlw2YfEWHkeXfrSmWAWieuj/a4bcP+bvZ3sb+Jito60rkp9WpcvqDKOX6IUfCrmY74dgKZjKifD9Nywqt4vybo5KETDtznzsoQJwYr0AwBCXoGRTI2uMmd3IwUIAe3b98e2DSXUcmNOIk6rzS1YGrQ7nRtn4pgXkhvzXl8cKU/6yPgWf/qG4V1/etkCHn9POysD3DevtK2lrnyX6UV/RSC+wzTyi60V6T8ygeuH5tpbjn2fZI4plOZOImKhF/BV1w43IurmXDI5pF38bw78+pmxYGNeYLV5R7zDPSaj5jbs/Nu3mN+bVMgb7CBTn6FF6dw4tL9fswxx9gHeX6EaP+WFve/s74E4TwG1vVijpDaF9qaWevY1ibquG+JoHfD7cm4vZF38f8f5/6M8D7Bx0A+Ravat+5XqyDwkZdFVB4+y7W6kXjjdBlEyla7F+HnpMeMvJGgRyq3CzdGd1ThrCLvks8rduZmdDXyqGKen+fK6DiI+k033bQNMf4Gtr7AddAM4WzyU6fmzoTdxN6E3tLJrx0C/0EE/iesn6Pl/m0eORyXi0uxx+wbALt3736BOO+gMuH9rGuxQVW8e/LMyXN0rolqpvW1RzP6xYyABD1mGdaYuRS+LmvWFS3oxj3qos6MbdVjx45dglDeiJi/UOoANBN5a8GTXuvxOZou/K8hwE9RYfzs3XffXfasdbQudxPHTJZJGvne2B3d+Dny19krpIy7cPLorvEU6Wy5BCTo5RKMkH8KcmeCTgHvpHsvQvgKMiXqok5LutpGldMdW5aop2FY692EHfHtjfj+T9euXefSDX9u+nyp69GjR2/lev0urcxX6QkoNZhK97fZFQDyxVlZ4spmhdusmQQ9QVcBouvk+TnisJ9nshsShKqspFSaqBssE3Za/W3oBRqKAD/E8/WP80y8nPLj0J49e1ZzzX6PyoJ9FrSsPKlQz84EHZ5OypIKzafAkl3ODRmYkYqoYAJOvpREYb7BJrMo2IoKcFiJoo7wmqib8vaikvfD/v37f3/BggVtS83uCRMm1NIS/C3hzlHXe/EU7TVD8qHR1xaLD/VdH1SwnJQlpdojf4URkKAXxikWrlzdhBQaLlsCsWCby0ivqE+ZMiXzuVp7T91ECm5uXhTOZYznGBWw7gjleL+739NRELY9Y+/AiPhPrl+//sd33XVXp/S5Ytft2rXbWltbeyfhadR7sfDede/k3nRVlpSWRPkqlIAEvVBS8XBXcsHaWPIQp22Nna/kc2lRpxt6WtRFHdH8c6kD5bLz2FrrjIpvT7pHH3HEET8staWe6vlZjIDYu+nZ0Wi/CQLkgxNBJ1onZUkTydHpMglI0MsEGBXvJibc3E5eWZOgN57LKVG/KuqijmiOLmf0ezaFlKi3o5Jww5o1a75Fj0BJk5xUV1fvIKzpVBA20auRHY32GyHAmAYngk5+VE+cONFJedJIcnSqTAK6e8oEGBXvFNbOatQUGlujks6o2hEXUUcwfRn9ns4HE3UqCR14Bn4L61vSx4tZWyud7vtl2Ha/WunFkGvWDP5OBN2sIE+clSnFpVKuCyUgQS+UVMTdIbrOBrHwTFZd7gXkv1fUaa12TXtJ+jN1E3Wukc5cg1+bPn362el0F7N+5ZVXbHDXNCoF26mcFuO1ot1yzW1xBYDKlbMyxZXNlR6uBD0hVwCFYNkTfuRD0blzZ7XQ88HJOm6izit+VzFw7GLvqaiIOo9m2vv5nno6jQi6qXBvrsP/9I4lSJ9vas0rcPt5jW0prfSn1UpvitZ75xlU6KyF7rJMeS8F2vKTgATdT5rhhtXGRfTc1LXDhg2rnw/URfhJC9OeATNqewOt1aez0xYFUadr/CYXop5qpRN863Msjuy0F7JP63wX7h6hclCIc7mBAI8r9rCyxfcf976TMsV3QxVghoAEPYMi9htObj4KanW3F3FppFqXj1LQ5vwyXdJFnZH0neid+NRDDz1U9EddPv7xj5sw/QlBX2UVI/0KI4DwOulB497XoLjCsiAyrnTXRCYryjOEQtSJoLsqLMpLbXR9kw+HWGY3ZmHURJ1KyA20jp9ktHrZkwchAi145NCHEeu3sF3Uw3CutUPMIb6e9RPqdm/sCmp4Ds6uetCclCkNrdeenwQk6H7SDDEsunid1KZpKe0IMVmxitpalYj5hm7duv21KcOjJOr0JryFgN6MqNu30MsSdcTFJp2phsO1Dz/8cO+mOGSfx18tHJ9nnX1K+3kIwLwuz6lyD0vQyyUYsH8JesDAHUbn5OajYK3Ib6CXkk9Uquz74i8z5mBjIf6jKOrktx+ibq303gwQvLwQDl43Xbp02UuXu820p9HuXjCNbHPdORF0rmUnZUojSdGpMglI0MsEGBXvFMRObj5uagl6gZlsLXTE6E8FOq93FjVRJw03+yHqcLCpcD9S7OQk9k46ArUC//ZeejEoK9mtE0GnUuWk16+SM8p12nXHuCYcUPiuatMS9MIzkC5rc/xC4T7edZk0Ubdud6tgsj6jpqZmULE8tm/fvgdR/7v1eOjXNAFYu6p0O2kkNJ0iuSiVgAS9VHIR80drxklt2mFhETGC5ZlDxcda57t5Fv1WKSElTdSNAc/jO3D9nFksj1Ql8s1i/VWweyctdMvCCmYay6RL0GOZbTmNdnLzqYWek/VhB1Pdwxvsq2GHnSzwQNJEnRa6fVq16Jnjtm3bth+/K2FZILnKdqZn6JWd/97US9C9NGK8TetQgh5i/lkLnd+qMWPGFP1WALOk2X1YH0AURJ1ehvE2+YyNfi/nmTqtc+s1Gjh//vwqg1Poj4+1oOX7V3NN16W4Fuq1Ut05aaFTqXLS61epmRREuiXoQVAOJg4ngk6B7ur5XDBUAorFWugsK4mu6G+gDx48+GTmfu+VNjVsUacgPzKXqDMDXMHvkhGGJaclgnwU30zvkk5bIeu1a9cegKV9H32bBL1pYlR+XN2jTsqUplMkF6USkKCXSi5i/ij4nNSmKVRdFRYRI1ieOSY8tEi3lxIK/sbgbzKi3j3tP4qijkg/V6SoW/liYp5JVzp9ja2/8Y1vHCKePbTQXU2Y0lj0sTtHvjhpoQNCgh6zq0GCHrMMy2cuguLk5mPktgQ9H/Ss4zzLtC+GFf2jQK7hU5WX0NKaFGVRx85PFiPquLX38lu1b9++qBY6fmzGuH2sXQlV0XkUZQ8MPnTFyUmZEmWWcbdNgh73HMR+ewZLa6aoaTYLTTYtdH0po0BY5EFJgg7j9qlPr14eRVG3j63YM/XFixcvKkbUEWQT9BbUCW1wXFG/Dh06WOu81sLQr0kCBT8KaTKkhg6kDw15RH5PGRb5LGraQATdRNfJTY1IOenKbzpVsXRhXwsr+gfjduYpqqKOfd3tmfrxxx/frlhRp7KCJjevT1+RYA5Qeagt0k9FOnd4j6p3LmZXlAQ9ZhnWiLlOut0ojNXt1gh07ynEqyRWMM4UnFEVdcS1fqBcsaKOv0NUBjLp8/JqbJtxBVYTaNWYG517l4BDQXdSpijf3BGQoLtjG2jIiImrm68kkQo08RGJDBHqWIopaN4uxCvjNUmiDpNDCM7uTOIK3GA8gol5O9gU6KNynbmqdHsrmpVLN14pl6DHK78as9aJoFOgqsu9Meqec4hQB89uwZtUxg77nnUSRN3EGDE/iDAU+yiiOd7suXvRz94Lhp4gh1x3ru5RJ2VKgtBHLikS9MhlSWkGUQAW3a1ZSEyuav+FxB03N+RBp1JsRvhyTnMad1Hn2jlEZWUHI/jXFcOFMSHWXdEGnq6EqhhzIu/WVaWbcCXokc/9hgZK0BvyiPOeq5tPXe4FXBWIj43oPrIAp4c5oeB8g67pw47bgbiKOiyMhyVq/erVq7fkTFz+g/b8vIbKQEfY5HelM/UEuHZc3aOuyhTlnCMCEnRHYIMOVs/QgybeMD4TdMTn2GKnObVQEK9lvNq120Qw1y+uos41aXOyr/j0pz9d1Ot8Z599dkt4HA2Lagl6riui4TE4O+nJ4Hp00uvX0Hrt+UlAgu4nzXDDclKbpkB1UliEi8r/2FPC02PdunVFt9IHDRr0FoXnYp6F5jUsjqJOemqp6LyYN1F5TjCpTGtGxvdlyeNCh7MIuLpHnZQpWbZr10cCEnQfYYYZFAWnk9o0QuOqOy9MXL7HnRL0Tsza1bvYwM8880zLu2cbE3QL0yvq8+bN65aOJyrTxHpfaaMb+HmunZ1VVVVFfx9+165dreB5HP7TSdS6EQKuWujkgQS9Ee5RPCVBj2KulGCTw5tPgl5AfpigI+YtWJ9agPNcTn5NN3OTs/KlRX379u0/jaqoIzCv1dXVfQxbZzLt6+JciW3sGLPl2dfZTjCm+jVNQM/Qm2ZUKS4k6AnJaVp3rmrTEvQirhHE7BKcF920pFB+GgF7talWupmSEvWracn+grnf+6bji0pLvba2tu24ceNeGzVq1FeuuOKKogbEXXfddS0ZFd8bFifCxJKrXxME6Mko6XXJJoK1sR1Oev2ailfnSycgQS+dXdR8OhF0Ctb2UUtoVO2hZWmmnfn73/++6NfXEMBdsL4fQW+ylW6R0AJuhdsreNTyI0S9D4fqKxFREPXU3O/VCELRTexhw4bhvfVJVIy62UBD/ZomAOfOTbsqyYWTMqUkS+SpIAIS9IIwRd8RrRkntWkK1qK+lBV9Uu4sRJCbIbJ9+P73SaXEAuupCPWaQlrpFn5URR0hrp/7nQ+6VJfAoS0CdTYMiu7lKCGu2HuZOHFia3g7aaHbJRZ7QBWWAAl6QjKcQtDJhywoLNpRaKiVXsB1YoLOyOxWCPPlBTg/zAld1G/jdxJiZrOrHXY+14Goijosjky31HPZnedY8+rq6i5UTi9K9XbkcabDaQJdunRx1TpvRj7oe/Rp0DFZS9BjklEFmLm9ADclOaHQUCu9QHImRIjZ1XSD1xTopYEzxHwig+OWIuwNjje2E1VRL7alzjv8bRgfcBZpH2yVI/2aJsC14kzQid1ZmdJ0yuSiFAKFlxqlhC4/gRFo167dNleRIRhdXYWdtHBtIBeN9IEMDLuolLSNHDlyNYL2HZaivgUeVVG3ljo8xhfS/Q6zDlRoLmdpLUEv+OpxVtmmcuWsTCk4dXJYFAEJelG4out448aNzmrTiIsEvYish1cLROlG5iQvaWYUvM9CoB9BCA8U2vVu5sVZ1GHVYs+ePX1I78Xqbi/8YuM6c9JC5xo8cPPNNxc1w1/hVsulKwISdFdkAw73tttu20dhWPRnKgsxk3Al6IWASrkxQaJAvOykk04aUoS3jFNa6XX0uHyZLutFFNhF9T3HVdS7du1qrfOPwu1o0p1hoY3GCXCtOWmhkwfbue+LuvYat1RngyAgQQ+CcnBxuOoik6AXkYcmSLSuaxDXz/AsPf98ro2Eec0116ygsP4CXc9rEbpGXB5+Km6ibq3zHj16HE1aR0vMD8/Pxo5QAXLVQndVljSWHJ0rk4AEvUyAUfJOgejkJiRcCXqRGW2tdFo4IxHXklrpFt2YMWOe4Jn8vxDOJgruoiyIk6gPGTKkPbyuJI0DJehFZbNdY04E3VroxVki11EgUFwpEQWLZUNeAtzcW/OeLOMEgl5dylfEyogy9l7TrXRE6nZa6SXPtjd69OhZiPpXCWejiTp5XDCbGIm68RlMr8beYtJXMIiEOuS66sB11s5R8oqa4c+RDQq2SAIS9CKBRdk5wrvRlX2bNm1SK71IuLxSZM/Sb0SQLyzSawPnzP72S1qwtyN29p56EgfKbSN9P2CU+1O8uy5Rb5D7+XdgZp+YdfLjMY+zssSJwQq0noAEPUEXAoX9BlfJoaA9wlXYSQ2XCpYJuk3K8805c+aU1TVqLXVeI7qZMF/mIzB7rCVbaGs26i11+LRbvnz5ItL2OSpBEvUCbwh4ORN0Wv4S9ALzIUrOJOhRyo0ybaGF4+wmpDVg84XrVySBVCv9gt27d9+6YMGCkl5jS0c5fvz4xylor0WgH6B7eguCXnBrPcqizrU1zj69umzZssWkT6KezvAm1lQWnQg619XBJUuWbGoiep2OIAEJegQzpVSTJkyYYJOR7CjVf2P+6II7prHzOpefACJlc7x/ecWKFefgqvCH4DmCpKW+omfPnrfQOvsief0KPSfbKdjrp4plP4eP9w5FWdSpoIyXqL+XV01t2ZsBXAO9m3JX4vkthF//paES/ctbSAQk6CGBdxUtN7mrVvpRkyZNauvK7iSHa4KO6Havqqq6c+bMmT3LTevQoUP38676pJ07d47g+fwPCe8fCPs24qj/QI8Jez5xj5Oo03L/E0Jfly8t5XKMs/9+/fodxb3e2kUaHJYhLsxVmB4CEnQPjCRsuroZEaXmTHbSNwmMwkiDdb0jTmeTP9+00cnl2oDIHbr11ltXXH/99d8irCsJ/1uI+58Q9Q3WaqdHwAaX1U8MYoLoXXCLKa3s06t3PfTQQ32ttWf2ROHTq9iVaamTns+zvMgxiXrWBUN+Oulut2gI29lYnKxkaNdnAhJ0n4GGHRw341pXNiAA6nYvA27qefrNtDw/6dcX7MjvQ3yl7S3E+IfdunW7kgrDSGu1E8fvOLcYgV+PwG+2FjyD6XYgjrtswV0tFbSLGHfx36ecckq3dLKiJurY9TWJejp33ltTYXMm6Nzn696LSVtxIlDWIJ04JbRSbKXgXm3C4egnQS8DLCJqX2Kzed6/1bFjxy08wphm4x7KCLKB10svvXQXB/5oi8UzderUo+nm74No9+W6OBph7Mi5apZWiH0t3e+7Of8Oo+dtJH7mZ6I+Y8YMe0xwtfXMZE4EtIHt9R904Zn65O3btz/fqVMnE/XvUhE5i4pKG+NY6T8YOBN02K6qdL5xTb8EPa45l8fu66677h2e09Zyw7t43t3LJkmxucbzRK/DTRBAIM1FFaL+Q1rM2xD1eX6Kejp6BNsiWplankkfL3QdFVGnF0GinpVp8+bNa09Fx8lrpFw32xl4qVnispjHZVdd7nHJqQLt5IY8xLK6QOdFOaOS0AJB0utrRVE73LGJOiw70eK8m5bzlX51vx8eU3lHotL9jqi3sZY6qVH3OxD4Kt2x5eVsft9cm07Kjvwx6oyfBCToftKMSFjclM66zAhb3e4+5DNdyCbq3RH0n3fu3Hm0HwPlfDDrsCAk6ochCf0Ajx1OcGiEs7LDoc0KOkVAgp7AS4HuXJe1bAm6T9eMiToVpE4I+08opL8+a9asfjwyKe7Taj7Z0lgwURJ1vsr2HM/+/xlmv+E630VvVGOmJ+4cbyTYY9KBrhIGTwm6K7gBhCtBDwBy0FFUV1c7uym54XvTRdw66DQlNT4E3VrqbRiAZhPFfA9BP3vKlCk2cC1SvyiIOr0ZN7399ttVffv2/SsD/b4NoCcqTdQHDBhwHNdLlYuLg+vvIIydvSXjwmaF2ZCABL0hj0TsDR8+fDcC4eTVEwSoVU1NzaBEgIpIIkzUWShPm1/LeiIidRUDG3thXqSan2GLOmy6M+5gfCWLOve1s+52rr9VGvAakUKhRDMk6CWCi7o3avFvuLKR7uFTXIVdqeGSX81S76mfTKH9YwrXLyHq76M3pCZKTMIWdTjVv9JWiaKemu7VWWXaZZkRpWs4ybZI0BOau4jCcldJI+z+UR2Z7SrNQYWbmkOgC4L+eTj/gnewx0yePHmgvaoUlA1NxSNRb4qQm/N0t/dBdJ09jqEHxFkjwA0RhZpNQIKeTSQh+9u2bVuJIDj5wAKFSgue05+YEFSRSwYFazN6Qcyu02D9v7y29YMdO3aMYNDcwKg8X5eoB3/ZUMlz2d1ey9fu1gSfKsXoJwEJup80IxTWbbfdZtPFrXBlEs95T3YVtsJ9l0BK1G0A4kcpzH9OBe1OniGPZBa3E1m6pUY8B4aLV+vaWbys60fiS9QDQ18fEZU7l4L+JtdT/axHwaZKsflJQILuJ82IhcVrUc663WlF9p0zZ07niCU5ceZQiNc/W2fdjsRdzvJTKlN3I/CfGDJkyEX33XdfP8sHm8HPReLvvPNOE/Gjpk2bdjIVjKvatm17y65du06257kWn0TdBfXDw0wNknQ5nkLd7Ydjj90Re6dRv4QSYGrRJTyT/ZCr5PHqkLXSn3IVvsJ9j0Ba2BHytiwfYLmIY6sR2OeouD1PPi9G1N9mRrXtPHffQd7sZUaxulRPzXsB5dkirOY//vGPrVJQxUde2tET0BH/XXmNqR89AyeyXMi5M6jItW7fvv35AwcO/A9E/R/WqjNRR/RDn/udgXKT7ZW2lStXfhsu9g36S2BTbezi/iO/z3CZBlgtdRm+wg6GQKReiwkmyZUVCzX7T1MId3eRagqZDRTmP3URdnaYtBBvpJC+i0lFjkhCAZ2dvlL2EVkTrWaIFkgObSE/liHE/4DRG6xX06LeyPIOomwfgDlgC+4OcD0c4lgL3FnXOUG0bMWxjvjpxvme7Pdkbd27J7LuSzztCdvisXfm7TOwe1n/lv3/WLJkSb2o47YZon4K7kL5oIvFb9cj6Z3cp0+fvYj6GVwv/8Zh30Wd9Ft0L5H+b3L9z7Mdlz/m+2/LR3T+BeZO5n8gf1fzxb5fuEyDwg6GgFrowXAOLRYKgdeI3ImgE/aRFDY9+LiIk3feQ4MWk4gRYXt/3axtTqHclcW+t352SuT3I3D2ydTNuNnK9h6O2zfSd+NmP8faIurWjW9fL+vAOaso2SMU+1a7hVcfNsfSA/Q4/O4P0axC1C61eNRST1Nxt0bMTycfnIi5WU1eL3JnvUIOkoCeoQdJO4S4+DSm05uVwkaD40LI1+woTdgR2vrn7daS5mefSD2CZQDiexbLRWzb45crWV+DGF/OcjHL+1lO41gfFvu8anNvOCbo2b+UyFfh3kT9W4j6SXqmnk3Jn31Yg7n5Wf6EljsUwrdKv34JICBBT0AmNpYEaz1TA9/cmJtyzlEYnKqpYMsh6Mavia6JvIl7WqDtHfdci503t7bkEvBcFkrUc1Hx/9js2bMHkC9d/Q/53RApG9YxO5yz8sGV3Qo3NwEJem4uiTpKgeCslU7YHZgK9rREAVNiCiIgUS8IU1mOuL/OLiuAJjyru70JQDE7LUGPWYaVYi6FwsJS/BXqh/AvSL+bXKgfuUsGAYm6u3x8+OGHbVxDf3cxNGvGOAqnZYNL2xX24QQk6IczSdyRsWPH2heU1rtKGIVOZ0T9JFfhW7g8A95HHC6jUNglEqhEUedRU/0bAyUiK8jb7t27XbfOV44YMWJTQcbIUSwISNBjkU3lG0mh+3L5oeQPgfDfz+LyNchthF8/2iu/FToTFgHyxp7VV8xAOdK7h+7q3a5433XXXVWE7/pRltMywRUbhZufgAQ9P5tEneE5999JkDNBpPXcnQE8gx1CewfB2G/CoV80CVSKqFs6aaFv55rf4SonjjrqqDOJp8pV+FQW6nhDQd3trgCHFK4EPSTwQUdr30jnBl7sMl5GVF/oKnxGZ6+lEN1IQaR+d1eQfQi3EkSdNB5ksWvRSXc141HaubyXLJupjCzUt899uOAjFoQEPWIZ4tIcCiDXXWy9+CJYPxdp4H33DRSiL5MGmxzFRRQK0ycCSRZ1rj9rnW+ls2gJFWQn41II2x5ftfUpO3IGQ4XBdVmQM14ddEtAgu6Wb6RCX7Ro0XIKpG0ujXLVsqA1YVOWPm5dnS7tV9j+EEiqqKcqk29wH/2Da3KPP7TeC4UpjjsRxznvHXGy9c64ceNWOglZgYZKQIIeKv5gI7cPaSC4LziO9Vjmj+/jIg5a6U/Q9b6IwnRfqmB1EY3C9IlAXEU9X/JT15wNhHue++iVfO7KOU4cQ6m4Op2Sm/vn+XJslN/oEpCgRzdvnFiGIL7EDV3nJPBUoBTkF7sInxbROrojv4P9yyj4DkrUXVD2N8y0qCNSl/Hq4c8HDx58VXpmwSh9erVdu3Yv8YW6L2Hvu+GdWAAAQABJREFUz7Bze/a1Zft0sdsc+E+yzBw9evRqf0k1a8az8+5c265Htu/hg3xOKiN+81B4xROQoBfPLNY+mAq21vXzMwrF43iW7uS99AEDBjyD/fdSwG6g8NOQ9xhcjSbqLK3JtzPIsy/zxsXwqIk6Yt4WUX+Dbx/8HKS/Qbwzop4Wd9KwEPtnI/ivcsz3a4/K9iVUFpwOEMHuFwv9pG4MLi2ZmEVAgp4FpBJ2uamfdy2GFEyXzp8/3/fXbs4///w9FHxTyKepLO9Q8NrXKyoh22KdxpiJ+k+BXS/q3Cf2NbJDLK9zbBKfnP0NPUU7/c6M1GMql699WjoO8I37F/22XeFFh4AEPTp5EZgldBduobXk9BU2CvCOW7duHeoiUQzosRHvPyUNP2W9HFE/YAWvftEmEBdR5zvqz9FS/ykVxd+ybMbulyD7Ex73PGTXniPK9iU8179/3HLLLc7enXdtvMJvmoBKwaYZJdIFLejnXCeMwvAc+166i3hGjRr1FvNQ303Y/50ueOkKPYS422tFLqJUmD4QiImotzZRpxv+bq6lH2LzD/iu/MwxY8as8gHBYUEwsv1k7se+h53w+QDpcX7P+2yygiuSgEq+IoElyfn06dPHkZ7jXaaJlvPbN9xwgz3z9v2Zo9m9YMGCtmvXrj2d8P8JgT+XVtQgWu72be821mpn7TJ5CrsMAlS+7G2Fl+kGvvP4449/xB6nWHAzZsw4heNXu36enM904t7AdTQZUd9HF3tbxHyfi1fULP4pU6ZUUxH9NJWG9vns8en4YnrmZvkUloKJKAGnr0dENM0yK0UA4XuSQtWpoFMo96EFcjpR/tUF+KFDh9YS7nOMEF5EC+T3pGcAy8kc60Yh2RFRb8e2eqJcwC8zTK4N+9rXfgT09FWrVlnrcaUFaaPfEXV75huKqHPdHInIjud6mkwXu9N5D6gsDOM+dC3mzaiYPGls9Us2ATVfkp2/TaaOgnMMBZjTTzRSYO/esWPHTxhd6+xjFt6E/vrXv+6ycePGGoS9A4JhM261ZCCd14m2I0IAMWvO6HIbA/EWreCNXrOi0lJH1Hd57fJrm4ruEO6NkX6Fly8c4lhEJen+fOd1PDkE1EJPTl6WlBJaSU9ywzsVdOtO7NSp0z9h4CMlGVmkpyuuuGILXmzRL8YEotJSp1vcWuq+ijqv7bWnEnM594bTHCKOQ1Rmn3QaiQKPDAF1RUYmK8IxxAb6cNMvcR07FYczXM0g59p2hR8egahMPmPPuv2k0LFjx8sQc1/DzGPfQocj8/NEqcNhEZCgh0U+QvEyAGhBQOZczah36wLXTwQKJpA0UWe8xyDE3MZ5OP3R82ZfhXvSaSQKPFIEJOiRyo5wjBk7duxaWunOp4Okld6V56XDw0mlYo0zgaSIun0alQr0FUHkBYL+EtzeCSIuxRENAhL0aORD6Fa0b9/+cUTd6RzvlkgGQZ3I63Jnh55gGRA7AkkQdcT8UlrNHV3DR8xrmbM9qJ4318lR+AUSkKAXCCrpzq688sodtKCfDiidl/I8vVdAcSmaBBGIs6hTkT0NMXf98ZX63EbQFwT1VkmCLq/YJ0WCHvss9C8BO3fufI6CYKt/IeYNqSWVh+v0PD0vH51ohEAcRd0qsPSABdLVDjr7xsFfGkGoUwklIEFPaMaWkqzUV5h+X4rfEvx0YSauK0vwJy8iUD/5DK3dOYik2/e+8rAm7vrJZwoZ/T5v3rz2VGCvZwnkNWHmXrAPyBzIY7oOJ5iABD3BmVtK0mj9LMTfW6X4LdYPBdwJfGb13GL9yb0IGIE4tNTvuOOOFjzLvhZza4LINXrYXkfMlwURl+KIHgEJevTyJHSLGLgzj5bP/iAMoaXzIWbMOjqIuBRH8ghEXdQHDhx4CdSPD4h8HVPWzg8oLkUTQQIS9AhmStgmjR8/fhNC+2QQdtBKb0nl4Vp7nSeI+BRH8ghEVdS5podwH10QIPHHaZ1vCzA+RRUxAhL0iGVIVMyhpm8D5NYGYQ+FXmd6BUbSPRnIM8Yg0qQ4giUQNVHn2fqRvKJ5VVAUqBSvhMGLQcWneKJJQIIezXwJ3SobVMPvEUT9YBDGIOrHDR48+Gp75hhEfIojeQSiIuq0zLtSIbZBcG0ConyA+8fu1VAGCAaURkVTAAEVngVAqlQnNoMcabfPWgbys0lnBg0adFkgkSmSRBKIgqhzHdv3zY8ICjCVhz+Sbs0IFxTwCMcjQY9w5kTBND57+iTdeQ0+a+nSLlo1ZzEBx1CXcSjsZBMIW9RtXEiAhNfQMn8mwPgUVYQJSNAjnDlRMM3eTae18WBQo95Taf6ApoeNQu7H14awRT0IctyTdYw9eUjvnAdBOx5xSNDjkU+hWknhuJ5uxMeDNILC6jIGFp0UZJyKK1kEKkDUH7M3UpKVa0pNOQQk6OXQqyC/fDf9eZK7NKgk023ZnBmvrmbimX5Bxal4kkcgqaLO1K4LR40a9XLyckwpKoeABL0cehXmF4Gdy/O6XUEl255F0t1//eTJk3sHFafiSR6BpIk6vVfbWH6dvJxSisolIEEvl2AF+edZ3U4E9ldBJhlRb0NFYvSMGTO6BRmv4koWgaSIOkJ+iHviYe7FPcnKIaXGDwISdD8oVlAYo0ePtm73PwaZZCoR7YlvwtSpU3sGGa/iShaBhIj677kHVyQrZ5QavwhI0P0iWUHhUDDaq2xLgkwyol7Nc8Ob+AzlsUHGq7iSRSDmom7PzZ9NVo4oNX4SkKD7SbNCwrIZqWprax8muZuDTDKiXkV341gGyp0QZLyKK1kE4ijq3HMbbAxLsnJCqfGbgATdb6IVEt6ECRNqeZVtFgXNvoCTbAPlRvKe+vsCjlfRJYhAnESde6yW2eBm8dy8LkFZoKQ4ICBBdwC1UoIcN27cBtIa6CA5Y0srvTmr4Yj6RbavnwiUQiAOom6D4FhsEFygvWGl8JSf8AlI0MPPg1hbQKG4kBbEgpAScTHfUr+MFrsJvH4iUDSBqIs61/bvbrjhhkDHqxQNUR4iQ0AFYWSyIt6GMFjtclrOZ4WRCioUr9Il+StaMQfCiF9xxp8Ar0WewnV0dar3JyoJepoR7YHO0BiVhMuO0giohV4aN/nKIvD6668/xqGFWYcD2aUVczJzWo/is5VBfa4ykHQpkuAIRLCl/rLEPLj8T0pMEvSk5GTI6eA75geXLl06BzOWh2EKot6fQXofQ9S7hxG/4ow/gaiIOj0Fr3MvzYs/UaUgaALqcg+aeMLju+uuu6qOOOKI8SSzVxhJpTDcxyCiX/Pc8ZUw4lec8SVgPTz79u27jBScHlYquH5X8MniafaVw7BsULzxJSBBj2/eRdZyvpJWzTPtm2k1HxGikS/v3LlzvgrGEHMgRlFPmjSpR5s2ba7F5DCnGF5fV1c3yV4JjRE6mRohAhL0CGVGkkyhgOxcVVV1C6LeMax00dqx1+rupyv1nbBsULzRJ8Drj2fTq/NhBsS1CtHaLUzWdO8tt9yyI0QbFHXMCUjQY56BUTaflvqRzG41gYKyXVh2UlDX8Wx9Hp9/fTUsGxRvNAlMnDixfceOHa+k0jkoTAupeO6iR+sevWseZi4kI24JejLyMbKp4HW2PhSY41hah2kkheZLPJv8jbrgw8yF6MRt3wTgmhzB0ilMq7gu91LhvG/s2LFrw7RDcSeDgAQ9GfkY6VTQpTmAgmsUhWeob1XQWl/HY4AHRowYsSnSwGScMwIMfGvJwLeLuBYuCvudc2zYT0Kn8cGVt5wlWAFXFAEJekVld3iJjcrEHalC9OkePXo8PXToUCtQ9asQAoh5X0T8clrER4WdZK7DA1RwH2B8x+KwbVH8ySEgQU9OXkY+JYj6YFrq11Kohjn4qJ4TBepmPsc6n+eWyyIPTgaWRWDevHntt2/f/iECCe11NG8CuPbquA9m82rlG97j2haBcglI0MslKP9FEaD7/RgKsxtpnVQV5dGRY2xZRAXjNwya2+4oCgUbEgGuseazZ88+nRa5iXloAzO9yed6280AuOlUJFd7j2tbBPwgIEH3g6LCKIpA6p3fMXjqUJRHR46txUTQT9Ji/zMFreaDd8Q5yGDtGmvbtq19X6BPkPE2FhfX2TYqGVP1GmVjlHSuHAIS9HLoyW/JBGipd6GAG0uB27XkQHz2SOtpAwXuo8yhvcLnoBVcQATmz59ftWXLlg+Sl+eQl6EOwvQmmWt9Iz0FU9UT5KWibb8JSND9JqrwCibAIKUOFHJjEPUeBXsKwCGF7yvY9YQK3wBg+xSFTdvKB3rOJrjzEfL2PgXrSzBULlalutn3+BKgAhGBPAQk6HnA6HAwBOgabcuUmzcQ27HBxFhwLAcoiP9GQfwM3fCbC/Ylh4ESMCFHwM+iAnZB1IQ8BWIpkys9wDVkj3X0EwGnBCToTvEq8EIILFiwoNW6deuuoaV+QiHug3RDax2dOPQP4nyaZ5/rg4xbceUnYELOvOdnkT9RFfJmjMn4O/bN1biM/PmoM/4SkKD7y1OhlUiAz6+2GDhw4OWI5/tKDMK5N2xbQiH9FBOBvO08MkWQk4BHyK1rvTqnowgcpHfnea6T37I+FAFzZEKFEJCgV0hGxyWZ06ZNu4RC8MKI2/uWCbveIw4ul+bMmdN5z549ZxDj+6Is5EaEVvkTiPlTwdFRTCLwLgEJuq6EyBGYNWvWGTwTvRzDWkbOuIYGrbGWGM/ZF+sZaUMwfuzRGm/JY5iBLO+Dcz/WkS6vsHEfNj6iDwH5kfsKoxQCkb5BSkmQ/CSDAC31o2npjKQ1FurHMwqhaQU57hZj76ssb+iZaSHU8ruxVxo5+z5YnoZARmKugvzWZs5s4Zn+bL5lvi5zRBsiEDABCXrAwBVd4QRooXXgVaTrEPVjCvcVrkvEfTfLQj4A8uq4ceNWhmtNfGLnU6at+ZTpQPLaxlAcHx/LmzUjv5fRS/MQFTm9lhanjEugrRL0BGZqkpJk3a6I+oco6M+NW7oo6LfSwnzVFsR9Q9zsd20vvTCdGIswkHhsOR5Ooc/xX2ya6UV46vXXX1/AoM6DxfqVexHwm4AE3W+iCs8JAQr/kyk8r0DYIzEHfAmJXI94vUEaVuB3ZSW25si75oyP6IlwD6KyY63xniVwjIQX8nEP9s/V19IikR0yIkVAgq5LITYE7NkqQnANBenRsTE6h6GIwSFEzVrsK0jPCrprVyDwO3M4jf2hyZMnH0FFphdpPpbEmIh3jH2imjV7i8li5pBn2xKQFiUhQQQk6AnKzEpIir2vPmDAgA8gEBchikm6ft8xcScPV+zdu3clg6u2xi0/586d23HXrl29yZfeJuKId2+WtnFLRz57yZ+DLAvoYn9GXez5KOl4mASSVCCGyVFxB0zAPsNKlCNYagKOOpDoqLDUIYxbEMTNbG+2Na/ybeYLYpuvu+667QhLKBOWWIXq1FNP7VRbW9sZG2qwqwt29mDbxDsJre+c+Wt5QCXFBr7ps6c5CelgFAhI0KOQC7KhJAIMmGuHyH0EQTm1pABi6glx2Y/p9WKfEvwd7NexXcc5e4Wuftv26c6v37djVAT2eSsCNrKc4627dOnSmoGHrWHZGj+tmVu/NUxtu9pEmzA7s1/Ddmfcd2K7osoN0v3SO++887vbb799L+nXTwQiS6CibszI5oIMK4vA1KlT+9N6sgFzJjj6NUIAcTKBt1etWlWaMDeCJd8p6x15hFnf3srnQMdFIEoEJOhRyg3ZUjIBWuv2sY5LEPazJVQlY5RHCFDZOUjl8NmdO3f+8bbbbquvAAmMCMSBgAQ9DrkkGwsmkJph7qMUyEcW7EkORSBFADFfy6OHR8aOHbtWUEQgbgQk6HHLMdnbJAGbjIZC+RxE/QMscX1vvcl0yoGvBGyWtwVLly79i0aw+8pVgQVIQIIeIGxFFSyBKVOmVDMo7GJaXWeoGz5Y9nGJzbrXeU7+IsuTlTjZT1zySXYWRkCCXhgnuYoxgUmTJvVg5PZHSMKxMU6GTPeZAGJuc7D/FiHf6HPQCk4EQiEgQQ8FuyINgwDTjp5AF/zFtNa7hxG/4owGAVrj67gGnhg9evTSaFgkK0TAHwISdH84KpSYELCJUfr3738irbMPYHK3mJgtM30gQJ5vQMifRMhfYzuUiXl8SIaCEIG8BCToedHoRJIJmLD369fvZLpcP0Ah3zXJaa30tNEi38jnbP/IyPWFEvJKvxqSnX4JerLzV6lrgoAJO3PDn0JBfz7d8XrVrQlecTptXeu87fDMsmXLFmrkepxyTraWSkCCXio5+UscAZtxDhE4j4T1S1ziKihBVMyW0OvyHN+gf7OCkq2kikAzCbouAhHIIsDrbkcy49x5iPvJCEOrrNPajSABelj2kVevkGfP843ydyJookwSAecEJOjOESuCuBKw99j57rV9+OV0xEIj46OZkesR8b9SAfu73iOPZgbJquAISNCDY62YYkzAppRFNM6gO/dEFs0+F2Je0hqvZXkVE17mwylrQjRFUYtApAhI0COVHTIm6gTsIzCMmB6CoJyErccj7i2ibnNC7DtAS/wNBrm9umvXrsX6aEpCclXJ8JWABN1XnAqskgjY99gR90GIugm8DaRrWUnpd51WBNy++74Uvq/t3bt3yYQJE2pdx6nwRSDOBCTocc492R4ZAvPnz6/avHnzQLrlTeCt5d4+MsbFyBAqRrswdznLYuYIWMpz8boYmS9TRSBUAhL0UPEr8iQSQMybM81sTxN20tePdV/War3nyOxUK3wFXenLmW//jeuuu249oq5Z3HKw0iERaIqABL0pQjovAmUSmDhxYuv27dsfg3j1YTkage/N0rbMYOPqfQ+CvZplFW8OrNy5c+fbeh4e16yU3VEjIEGPWo7InsQTQMybP/DAA914/t6bLvqjSXAvey2O460Tlvg6KjD2JbM1+/fvX1VdXb1qxIgRmxKWRiVHBCJDQIIemayQIZVMwER+xowZnRH47ohfd4SwO61Ye/e9K2LfLspssHM39m028SYdG7F3Y11d3UYGsW2Nst2yTQSSRkCCnrQcVXoSR8BelSNRNYz0rkE0a9juhIja2oS+Ddt2vk2qhV+/j6iWNMMd4e8nHBuIVr8Q9r70Pud286x7O2FvY8KdbbZm4Np2DVyDln4iEAECEvQIZIJMEAG/CdhHZ84+++zWdOu32bp1axvEuE1VVZV16behYtCM7XrB3rNnzz56BOp69uxZ98ILL+zTR0z8zgmFJwIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiUKEEmldoupVsEYgUgQULFrTauHFjp/3797c+4ogjdn74wx/e3bx580ORMlLGlEzg0KFDzR977LGOO3fubN+hQ4fdl1122U7y92DJAcqjCOQgIEHPASWuh+6///4hBw4caG/2U4DsvPHGGxcXmpaZM2f2GTBgwLozzzxzX6F+/HA3ffr040ePHr3cj7AaC2POnDmd9+7de8QNN9zwRmPugjhHPvXYt2/fRynQzyWfTmE9iHUHb9wcq+PYm6xfZ/1sixYtnsT2F9gvW+RnzJhxFOH08cZX7nZNTc2rw4YN21tuOGn/s2bNOuGoo45aOnTo0P3pY36uyYM23CunpMNs2bLlipEjR25M75e7NvsPHjw4gnDOJv/OYN2LpYUn3H3kwUr2F3H+GbYf4379u+d80Zvcw6fjqWXRHnN4wKaiyo8cQehQCARahRCnonREgNbdTG7E+kKKAuJZormg0Kjw990lS5Y8hfuJhfop1x3C8gHC+B+Ws8oNqyn/e/bs+R5pvJqW8DGIRG1T7v0+T9wtKORHsP4cYn4+4bdguz6a9NobJ8fasG9CP4j1RxGHZvBayTKjqqrq7muuuWaV132R26MI73+L9NOoc1qeA3CwrFFHRZxEbCevXbv2b3j5eBHeinHaGwYvejx8ku2fefaL3rReFmweS559AftPbiKA1rjrhxtbhrP9PbL2NbZ/0qpVq0lULvY04f+w06TnDxzsfNiJ0g48g7f3l+ZVvsIi4K0xhmWD4o0GgQEUKl/7y1/+0jpAc75OnP1dx/fQQw/1JI6bWI6kwL3ZdXzZ4SPkV9B6eo0C9wHSa4VkqfddX/x/tba2djk9G7+0ln52XEnaJ60fQ+S+Foc0YedVa9asWYLN92JvU2KeM0n4PYHlbirmy7hebszpSAdFoBECpRYsjQSpUzElYMJ6DK308UHYT4F1PoXXxcTVGWHq7jJOutq/QFxVFgfrLxJfS5fxpcN++OGHj0B4Z9Fam0e8A9PHvWt6Utax/xjrH7H+Jut/Zfkuy3SWVziW6zmrVbpuoaX/OkLyWcJO8qOzb5PGUaTX1x/sfGHGtVSDfdPIgzkYeFwOI7eTj39guZPFKiefZW15/H8sT7K/I9sPYfWi8selM/1XhN81+7z2RSAfAXW55yNTQccpOLpQiNQXHKz/la7D+1w9u0xjJZ6vp7dpkVhlwrfnl+lwbW3Pznfv3v0Jz7HjKMyvY3+W55jvm7TKB9LN/ygBH9YDQUG+huO/pGv1QbpWX20scivQ4XMpvMbi78OsvZWRTuzfhaBchrvxZTwD/jw2mE3l/Kxi4vuP9JHs5pOoAK4aNWrUU35F0Lp160NcB2UFh019yJvHsPHE7ICw+RGOWR7/lnypyz6f3iff2lj+sm89R1daetPnbB8bX8TNpYRRyOOM6xgLkLeHjYrldMLsYuFj30LGZXzJtvP8tuQ5rsMRJiBBj3DmBGjaAE9cx61bt24c+9Z16ORHBcIGClkhVv9j2+J/LrXr64ru6c8QYMesQL/CvjNBpwA+jYL4D8RRX3im46YQNdH7Dwr5KY0V8mn3tsbdZlYzbaGS0I+W21fZvglm3nv3MuL7K/EOw32jFQT8HvZDBB5jwN2Sw05E5ABprcIUa62eT/pej4JZiPmx2PU0S+8se/6EUH6eysfLWcdz7qaug3mcnEf6Tkbcf0iYH/I4Pp68fYa8/yB5ZM/Y8/4YXPp43pOcoOK3l7DTTjYT3mPpHa2TQUBd7snIx7JSQQHUoBXJTW+tdK9glBV+Ds//kXXMW6HIOlX67rx589qTlttzhHAalYpMhSLH+ZIP8bz+GArg+QTQQMzZv6ddu3YnMJL5l6lCvOg4KIDfwP/HqBCcQeXg2awAjibep2fPnl3wQMgs/5HeJR+tp+JRRM/p45lCIFivDxWrR7HJK+bW3P88+fPBQsU8Oy6rjOH/w+TtxzjnHbh5JK3r33FtHZ3tR/si4CUgQffSqNBtCqZsQe/H4LHRLnDQsjmdcK/whk0B1iB+77lythl5fStpyykAxGktXV9/iE0bntfPJdCenoDt9aRP0Hq69eqrr97qOV7yphX8iMaFhPufBHLQE9DrHGu0FedxG7tN8rIflZZH4NwuTON5lDKF+Id4bNgB98vJ4x+xzjSBPeeL2rRKH70mQwnL+xjqaHqbHgh40GpRdstx+AQk6OHnQegWZAu6GcSxf6fg9D6v9cVOws1unVtcvrfQreCjFfUvHqNN+DLdtcT5Qev695wve5MWpI3aP9UT0EEK5TEU0BM9x3zZJNyDhPsNCv4RbO8h0D8xYckliL110Sf5dy6iboPQQim7qJDeSNzDPYD30WNyJXnxe8+xsjfpjXmefLVepB2ewM5l0Oq/e/a1KQINCIRyUzSwQDtRIJBLUPvTzefrqzNUEOx1nquyE0zB5XsLfenSpdbD0DcdF3E8iPh9Jb2fWmfvZ50ufJfnk4Nx3SA8HmV8kYL+/sJDKd4lBf9c0nYBg7w+cuWVV3oL/+IDi6gP0rcX07Z7zBsBb5u/INAf128HxPxHWZF+5vrrr1+QdcyXXeu6J+3ZPWVfwQ7f7xdfDFYgoROQoIeeBeEbQKHhLSAyXbgUXr620mnB/jthekfx1ieeYzUUUjm7xkuhY3GwfNnrl1bUdyl4beTxIs/xq4h3kGe/5E3i+w+WVukAYPp7xPb/0vsu11bw0zK3VnpSfzuojF0H0/2eBH6B1vKnPfvON7l+P0Ued/NE9Du62X/u2fd9kwrhPNJ9bzpg4q+y+yi9X8wav2U/DigmPrkNnoAEPXjmkYrRBvh4CykKj7vTBnJ8IN2bN6T3y1lbC5bwrk2HQTwmrpkfvQG5egky54vZYGDY1cR1gsfPYwje34jT3oH6vud4C9L3Jc9+SZs2+hyPXk7WDXubxVdSgPJ0GAEqR7+D523eEzxSsVf2vN3f3tO+btsjHK6pBo9wqGR81tdI8gdmPT+70qex40Yqoj3S+4Wu4XdYZbpQv3IXDwIS9HjkkzMrGWjjbZ03Y1pRm9TktXSEbFur2o/rxFoV9eEQ5h6Wz7AcSMdDHL4JOpWDBgPe6Pr+bjqegQMHziDet9P7bI+lpdcrvV/KOvVoIsOIMO+lAvFmKWHJT34C9ETcS15+y+PCps+dyViI93mOOdl8/fXXP0TAR3oCf4BKRiCv+tFKf4dr6meeuFtTEb3es69NEagnkCmExKMyCdDK8Qr6LuYIXwuJTKFJgTmYVtDIcuiknvllWrCE+TMKZxPVjLByzGtHydEhzpfg+ax0ABSETxPXU+l9+/gMcf0wvc92Gxh8Ib1fypo4sgvXH5cSjvw0TYC8/DqupnpcVsP/1/a6oOeYi83rsgL9Zda+0116fDLd7qmIrnQaoQKPJQEJeiyzzT+js95BX24hU2jOppBc7InFng+XfK3Qmvg3/LdMhVfbtm3bH9g2x97wxOFLCx1x/ponTIsj0zpPH+/UqdMv2N6U3md9mz168OwXvGnTuxJHZqYwuP2dFtXCggOQw6IJMADwVjwtSHuEfw96muaXmofpcBpbk68fTJ9ne3OvXr2eTO8HsabHx8Z+ZN7SwIZz9QpbEOTjFUereJkra/0mQGHoFdJ6QaewOEhL91ucm56Kbwj79vy76BHbtM6PYxDPGMJKm/7zVC+A7ZugX5I64bUjdai4Fc+yz6L7Ox2eef4bg5bmZ4cyfPjw3aTnx4j/HalzHZke9lNsfyfbbVP7dXV12d29f2jKT9TOw2Ec3dbed56LMpFK4SEqgXcV5akMx4hbHeI9gvfBbaa2dGVqCPsPIXIf8fsTwLCxqZGPTZvM9p9dT42cjitr/Rz7g+wYNrRbvny53TMm9AX98JO5CQvyIEexIyBBj12W+W5wpqsbIc+0mBnwM5uC/uvEVl+AsLZW+gO4KapQQMy/hr/66wy/e+k6zAxKs/g8ZUzGjlJTmN06J/zDWufpsEmfCfqX2K+2Y7i9ndnx7iz206pUILyD7yyol+wvTj/y4N/KsRf/dk0EJuhmq03SQ2VxGL0/z7Pb045hxsW8p21d4eNt368f1+wQruNMcFwrr2R2Atyg4vR3rtlMjGwfz07Bgo7d9vZHxr82kkeg5G7U5KGozBRxg2eElPt9eZoCraAD7P9Xeh93J9ECvia9X8iaVnAf3GUKV8KwaU9Xe/xmKhAc60T39ZGec0VtYpsJa+Ydd2xfSqvxwXyBYIdNwJJ55QjbjmIO+5vyuW/keBfvOSoK3vR5T2nbZwLk4Up426yDu9JBk4/W23BHet+PNZW2rlnhlPshm6zgCttFwDd4XbJ/hHe/qW3YSM2bghTz8xL0mGdgOebPnz+/E/4zIsr97hXYZrRMZpowpuPgvLXSC371BbdfZWlj/gmnjhbGf6fDsjXhN4iP1lbJ3e4Ubl/Jsu37xPlec8YbcWqbZ/l3srkvfQr/RX9alThq0v5tTRjbvPvadkuAkeZ/JYaR5MMBT0zfYCBnpiLpOV7SJmE3GF8RVh5jx/asBNT3LmUdy7uL/4Lv3byB6ESkCdR3hUbaQhnnjMD27dszrXOLBMHNtNBt31rpqWfpU2yfguwUWsLWCp5j+4398NcL97ek3bB9L4PF3k7v25pX5N5AxDOHEGUT9GcyBwrcoOu1L+HcmHZOubWaykK9zeljudY8y19FwW/TiE6w86z70RqzsQKzc7nPc8wrJM1IQ/s87oo+jG02F/3RRXt818Nixg8U9EU5WrnnYfeyEuMJ3ZuNk4DVpzEk82oX3H7Bsbe55v5QroGE1aBlG5YwEm9Vlil15aZN/pNFQIKerPwsKjUU4l5BP4igv5UdAIX9DNx9neP1blPbTQo6Bc+XWapS4e2jNfy97LBtqlIK3Y24627nWHvtyXaed5/nm1/kZGuPg/+hMlJoYWct+ZuIu771wtom8ShG0Bu0mmBYVDeox+bDNrHFKkTnHnaisAOWhoIEHXeb7V3nwoKNpivsn0hX+3FYZ/lnP7seHub6uoBzZb11wPWxi7yoDzT1Zz1bYfyye4N2hmGE4owuAQl6dPMmCMsGeCJZlUsErZVOofhfFGj3pdyeRiv9Sro653r8NtjE/VEc+Hj6IAXifbSGV6T3vWvCtW73ekFn7bXH6yzvNq3z7rTOMz0BKYen2Cj2vJ6yTlBJ2cyheiHGntNJ34dtZrIsZ/l2G6SLFv4pOMzLJl8gfh+HeYOKht/hRzE8hPtr5Psx5GH9nAesTQDnc42cw3W8rlSbYbnK65dwreIQ+I94G1R4qTyuDNwIRRhpAhL0SGePW+OyCogGz7O9Mffs2XM6n1O15+f97DiiZS32vKKFO2sxtzO3FIb76f7OO9qc8zbSPd0KLVrQaZ1/jmgadHMT3gQWi76kHwJvrbyCBB37F3rjYj+dlpLi9noirFr293iPNbLdEjvqxyuYG7YbPN5oxF9iTsHrEONCbtq6dWtv0n9hKmH2OObXv/3tbz9w6aWXZgbPFZNoHg0t5bXGjBfiOT2zE+xGg3hJ45Jgo1dsUSegQXFRzyGH9lEweWv8DZ6fe6NNvXP7bc+xM2jF2ujiw360zrtx8JOeE1NoHb3p2c/ezFQkKKC89mS7O2x/7ty5HfFjz059/RHmxaTvrEICHTBgwCu4e6+05716vyY4ocU5lKV9IQs2/KvXXlpvGa7e40nfHjZsmL0aaeM8MpOwsP2+d955ZxYt9ZalpN9ekeNeec3j95x58+Y1qER6zjnZxPZ2XJfpSopVlJdyXcT6MYkTUBUeqAS9si+ATIuYAqJRAaCVPhVUGdGnFfuNPOj+meP1o28J8wDC8p087uoPU0h54+2Y6q5vzEvm3K5du6zi4B2BvIU4V5e4NOiSTbXSM3Hl27BJTIgv05onPVW05kblc+/w+Pu9YWP/i979StqmArmZ2eQuI83e17yuoKX+o1I5kMdPpP2Sx+0YUPrR9H4Qa3qihhFPphKBPY8HEa/iiBcBdbnHK798s5YafwcKOHvWXf+jgMiIdfqYd22tdJ5PfhuhuMeOU6idySCkYd6Z2Ni3GbU+4/E3jWfRXsH2nHp3015d807awVGrZKw/zGHWAbpWq+ha/UL6MPZb1/5pFOYlPVfE7uak71XWJ1qYrK+mlT4Q+5vs1iTu6bi3VmH691X43oMtdekDLtdMiNOWRyIXpePAnjW03jKvG6aPV9LaeoW4HoeT5gUsaSH8NBXGN2GTmcu/CCYzceu9tu1RT6GDDouIJrdTrq/MtW4uqCgHFndui3Q0igTUQo9irgRjU3b3dqPCayb16NFjCqtM9znC0aCVzv7nOd/R3LJ9gELI201vhw/74a5BvOxn23WYHzuwbds2e07ew3NyeqlibmEQ7yFW3/OE14LKy5c8+3k34fIrTq7yOOjLOIPbPftON5kQZzQsuqYjIS1z09uVvKay+QJvadwIg4MeDj9A1K/x7Be0ScXuORy+6nF8LuFYhcH5j4rJPxHJBemIyN+FXOtPpfe1FoE0AQl6mkSFrRGcBsJJwddoC93wWCudwiTThY6InE2h9hE7R4u0hn2viM2kQG2ylUjBtA7v3sFKmccAFm6uH3G1zBJbK7C9YpzLW5PHeKwwC0eZCgvbY/mKV/20oo15Ni60mP7T6wb7voWdQ7zHXGzn+E63VU4muYgrjmEixHPhYRXN+h/XaHM2pnL9n5c6VNCKMA6Rxw0qqIT1Y7vuCwqgREeE3w6v/8/rnXjtE8dWAdVPBBoQkKA3wFE5OxQKXuHcirBuLiT1fE98MoXJWx63X7dtCsjPsuqcOm4C26DwSx3Pt8pUJhBCr1053dNFP5ITx6dPYs8culEXp/dLXaeE+ftp/zCq2rt3b0YM0sdzrakQ3cvxlzzn2mLngxTImZaz55xvm8xd/p/YeUI6QFg8hYhV7PPzNAfvmmvDXmH83/QxeLXjOqt/dJQ+VsgarvfD92mP22PI4/sIz1k5Svg/Ib5M5Zv4nyM9Mzw2aFMEMgScXYiZGLQRVQKZQgIDM4LalLGpQWDeVvp5PGseQaGWecZHoTO7GIHFfabbnW2vXfnM+ar3BHHnfS3O666Q7ZqaGmvdrk27JexPFNIKo0J0AFG/Cfszr5nh9wTGKTxaiP90fMWs7U0D4vhy2g9xWysys58+rnWzZlyP9irlwx4WbT3bBW2m+H4cx5keJfhfVcycBwVFlHJEuN8g/JvTflLX1sfMjvQxrUXAS0CC7qVRQdsUFF7hzAhqIQh4Ves+3GUmVKF1boPC0i3RgwxO+69CwvG48cbfaAudQu5y4jrF4/d3dO17W8aeU8Vv2mtPiOKdHp+daCV90rOfd5MW3D+w7RNZDs7F//OIupd3lpPidxHzK2llPoTPzD1M3D/FhueLDy35PhDBg4x8H0NKy+ID39cIq0Eew/1TPHq6jzxu4wdJwmvBc/Mfkb93ZIX3aSomC7OOaVcEMgQyhUHmiDYqhYBXOAtuoRuc1PemM610DrVNQ6Owe5DW6qL0fiFrCrCMoLPdgYLRO9itQRAUcl/zHkB8fWudp8Olpf0z0uF9BPF5G0mePt/YmsrFFPw2sJE0DUbUX6aQ/pwV1o35b+qcPTNHPL4JhwcJyysgL/Xq1ctaofrlIcB1uYe8Gc7psuatR1Sncd39hzca8mI8vTFPk8fe+8rrpKBte7OCSusfcXy71wPxfYt4NTbCC0XbhxEoq3A5LDQdiAUBZs2y98Qzg70oLDKCWmgCaO3ch9uVXvcUlodKaJ03Q0AbxI/45WzNUtBdSHze0b7P8YnUJ702+LFNwb+TtNyVDovC+iheCxuf3m9qTcH7Pdz8m/FIuyWMDmz/H2l4BUEeQwWhVfpcIWv8N7cu9tdff/0Ftr/OkvFPPEvbtWs3jDEAtYWEVcluyBubjGUYy6ZyOHDd/Rf+v5kVxlnkxavk7/dZMq+EZrnJucvgy6Pxcxe9Xa+Qtw3mFCDM7xLf13N61EER8BDIFAqeY9pMOIEtW7b0z0piUS1084vo1SFO36Wl6B2B+zDHva/2ZEWTd7eBoOPKWjlPZ7vO0Tr39hJkOy9rn0rGjylYv8hiQmzvpdv2LyhcDxYSMC3171BAW0vwXharQNX/COMkNqauWbPmh5x/hO1HmVr05Vxz3dtsZDt27DgXPxfB+kbWh7X+sOcvfPjm8hEjRmx4N4bi/xGRR7Flb/E+3/OBUFq6YvGzty/g+VGupycwuKCel1wJI5w74LaKcz8hb6rMTWr9JfLlc5ybz3oex55lMOkbqZ6t+qCse574T4L9Obi5msGXQ3HXoDzmeB3LPyPmd9d70p8INEGgwQXUhFudTggBCpL+WUnJFtSs07l3Eb17KYT+laUPBY8NyPpWbpeNHz3qqKNW0ALe7ynQDhMuCsDT6NK8zBPSq9dff/2jPNP0HPJvk4rJZrpPf0aI6W7s/ojAtezfX2gsiBxm3/8iPQ6/JG0XZ/k7kmO3cuzW2traZhT+O9nfDMftHLNpPo9AzO1VQHvNyoTCVpmf8WbnJ507d/6SPffPnChto392+MUEk7KlGC+hu0UknyU/x5Lu+9OMSzGKPP4lefwX8ngy4WTGdrBtj0OuYn2VhcvbCAe4nmwK2VqOdcS9TVucM2/NPe5e4/xNhP+C7esnAoUQUJd7IZQS5oaCwivo+xDmt0tJorXS8Wfdy/abi7i+8u5mcf/2uhg2ebvvDxN0xPyr3lAp8Jy/i0vL907iyYglNn7Fa0Mh2zCymckuobJzBe7zvk5G2NYT0Jf1SSz92O7Mur7Az44Hm/7Acjbh3u6DmGcHXzH7iPqDJPZL5SaYPP4bj5rOII8/Q1ircoVHXrbk+BGse7Pu1EjeriNv/4UW/an0AEjMc8HUsbwEWuU9oxOxI0Ah8RSFQX2BwnbegWm42UHi5lsCcbeBAulAqYmlhXgPU7BeXMqzc2+c2GQt33QLp0GhyDSvnYijPefrbWa9nfjMvdMf3eBraVV9A9suSkdEa6w/vIoeVIV4PEoYj9IqPJ0ekrFsX8oyJB1uAes3cfMrKl+TS604peNAeN7EhjTL9OFy1g27D8oJKeUX5s+wuTG1a70WTn5Uin5IHneEh7dCWXRcqXvobgYt/pzW+NXcVzZD3T+xZB63NBJoLel9kmUGsw4+4HAsxOPE0TVlxz8asUenYkogZwsgpmmR2SIQKwJUDuxb7qdSyPZDAI5m3YG1VVzsgy+7WFaz/ybrl6gQrIlV4mRsMxv4uH79eqvADSEfjyMfrdelHeu9rLdRsXqLZXHHjh3/op4WXTAiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIxIdA8JnbKzIgQmDVr1hk33HDDX/0y5+GHHz5y7969fdPhtWzZctXIkSPXpff9Xs+cOfNYwuyWDrempmbJsGHDtqf3y13D59Sjjjpq4dChQ/eXG5bX/4IFC9quW7fupPSxqqqqlf+/vXOBt6qq8ziX9zMYBbWHRoBMNNKQg2lUKIiQFMlDQiQlCM0XCAlqNZ+mz0yDmZYPHvXxMWMgFDrgEIoyZpraYybTBi1lqPE1gIDIQ7kgyGW+/+Neu3U25567z9773HvOvb/9+Zy71n+t9f+v//rtff//9dprjx8/fpujo+E999xz1KFDh/q49M6dO28455xz3nR0U4Rg/z7ub1057m8Un2j7ampqDrZu3Xr/O++8s+P8889/PZqfhKY9x8N3rOOlbc/QtkOOThNG/y/SyHK8POvP8qy/7WiFzQ+Bts2vSWpRuRDAWY3ESaxbtmzZWVOmTPlZFvXs37//uMOHD//WySL+MPGRjs4yxMm1waA/Sh29A7l7d+7caUY5k2v58uUfBZ/f43gvRODSTIQGQrZv396nrq4uxGnfvn0zyVpYXx20cxTtXO7ya2trRxB/xNFNEaLPavTaS91nZF3/li1b+iI/xKeQfO5NLpn7tI/Ien6Ptm3bdilO+I+FyjeUxv2YQxn75S4ceg8iuwMyVcD9nYyAm1MJiTDzrPcnaWMkWWQzQqB1M2qLmlJmBDBgXwuquDarqhgtrWf09JgnbwQdhwEenVkUZzLWc+atGLEtoWOyM7MKWrXK4UMdV/PT7JcHLJ3AEWAymN/pjGxP9bIyieKYD8cVhA6d+J3K71qeiefQbQmdve5x+V05ntvYdToehUKgnAjIoZcT3WYkGyd7GgbwjKBJZ2IE/y6r5uFYFzhZ1FHDz0af5biudELNGHPd6ui0Ifj0RcZEk4Pck6BHp5XZnPjB23UGDZ9rKqVt6GIdrwtw7L/BqR9XKXpJDyGQBAFNuSdBrQXyeKNz13ozyl9wRJoQh76a6dBXkJFbS6euCzGuX2MqNJPpS9MNB3sydXza03MdswMveHSqKLKvRkAbJwRHYbMYDzi6JYd0/j4OHsM9DMYy7f3hLPHHIefNiNCB+B7P1ROuTupvza8L9DGEAwmtw3WMl/9hZKxiLX5o3P0PyMmr08kqR0h75lDfpjSy27VrtyUNv3grHwE59Mq/R02uIcb3b1BiTESRCTjdfjjdP0XSSyZtIxF1LMJgXR8wd8FBTid+U8nC6mFA3uxI1i0ROjG5cuXK97Kxbyr6hzKIf4qp5SGTJ0/+VZjYQiM4o2sj2JBUMw84vpwVJDblfvDgQV/c79i8udpP8OM8u7af4ivo9T3SO1oe8U+wFn8x0cVGN3TRBlj+cs8bKp8mn/Y9yP/JhjQyxNv8EdCUe/O/x1m00AxydDTSGgNqRjmTC4N1BwbSNivlLuq7nF8mz6dNpSJ7kpNN/AUc7TpHpw1x5jZ66hCVQ1rFTC1HdWssmpkR2w8xtkB9X+S+vL9AeqMkWSeSGYLFPAt5s0zcs6saRQFVIgTKgEAmBrMMeklkhSDAKLM3qpxXSB2M4VRzloXySk3DwL4Bz92OD8Pad8WKFZmsQzMSuxR57T3Zt6J7JkOr++67rweyL3GyI+EY8PlIJK1FkSyfXAM+0c6gjYbbc1/mNDUYOPU16PCgp0cf7plNyesSAlWHgBx61d2yxlUYwzuPX7g0gyN83mlAegdG6dGpbJddcsgoPdwcZ8w4g1klC4kwrF27tgN6+g53V8+ePZdEiiUmee3uCpi7OQERfGpwWra23iIvHOMJYH++azzYbCUevlVA3sXWIXL5aUJwPqLTEFcea+15ex14pj8ahxf9E9cZR77KCIFSEZBDLxWxFlTeDregudO8Ju/EKI+A3uXSoC/Bab7H0WlCRunPIu8xT8YI2zzl0SVHd+/ePRkma0fuQv7to0aN2uvoNOGaNWs6Y9T9nfMHOPDlbGS+7OSaQwsOIHFJLSbEyc6lse28Bn8f5+m/O9+N960v9/ITR20NPSkzz0TeZjF0jNXJgC9xnUl1FZ8QKIaAHHoxdFp4HqPP2TikTg4GM8asPW8mXOzSyO/OgRWXOjptiJEMXyVDto2AZqaRySjfd7iHcLiL0sjzed96660Z6BieOkfejyZMmPAybbCNVu5qR5mvOqKlhIzOe9HWGV57d3Xp0uUHYGH3t9alQ8+yU94c3RQhOuQ5cOi34uhBOY3Q4wClMo2GgBx6o0FdXRUFo+7LPK334gxzO8MtxGntc3nEZ9vUtqPThJy29VP4wxEucXuFrXsSmYzuT4dvkMf77+ZwPTpx9KmnnjJHbSPQ3AUGhxglfteIbt263QkdHi9KuYtow1HvlmwZfxmdX0m7w84grV5oR8+yZv062NzpoXDM5s2bp3l0omiaKXf0tOckvKDDZaUwUREhUAUIyKFXwU1qChV37dp1GYbNd6S3cXb4DtMlOEP8X51elDuOqW077jT1ZbuPMfjhKBrZXTHWSQ3+7IhCN0foxOTGjRunoNvxnoB70T33Ct+YMWNqybORqLu60AZba28R1+rVq7vRfn8qvZZ7Gr4mSMfnRmj/rPu5dHjCd/gbEyTqtXX+L3h1buN97d95tKJCoGoQkEOvmlvVeIoGU6ChM8T4HsDI+dPIrQKjfMhphVG0zXOZPE/IthFuOANAHVcgu8bVFSfEUH8Ins97ZZ9mdPikRyeOmi78rokIuC5CL6QN/tTtTFtzj5RpluTevXttCaaHaxw43G4jc0fT8XmF+HJHE/ZhI9pEjy45yjNT8nq23Q86Wj+msnDKn+WkBdapLFkBMQiBCkAgEwNcAe2QChkiwOEa03FYx3oil2Dk8k6pgn6RMitcGeIn8s7xeEenCZH9Bvx3OxnI7lvqUao4iJnw+893OEJ0cpOGvE43Dp3CzXo4rAdwWOt9ecEZ8be5NMr33LNnz3RHN9cwWHrxX0c7CD55ncGg7d8l3XfC0Q5SSRCVOuVOh28Q9+Nx7ssQVxH6PM+STyFdXRGFQqCiEWhb0dpJuUZHgNF5Wxz6PK/iOoxebm3YS8tFMYDXkxe+lkTcjPK/RcsloRlxLcApX+R4g1fYHnB0sdCmfNmwFp5Chp5bkfeTYjyl5KFLeC658SF/fiF+6vy+TbWDi3sHfi74/jDu0aKFZFZ6Gksv02hveDYB2CxlI+WrUb3pAP2BPQ72DribRRnEEbGj6AhldeDPB81pc0KgzewY/l25b6aXdcSG8Wx9kjC80PNV7tdn6UzuCxMrKEI7LgSfbWlUAtvMOrVp9BBv+RCQQy8ftlUpGWd+Hga5t6f8vRiCgp9ctFEpRnkt5UdbecLBvKJ1Jgb8EY8/URTD+iwG7FGYh5kADO5Z9gobdb7QkECmfL9EmfBVOnh/gLwDDfHFyUen3FfDvLKP095feXQYpc5NlF9KgutcfJANYJOgl4WFmlEEB9oGR5nXGWQK+/oiTfwOec6hWzHrECZy6DjjvKNfeRavQ5foMojVccTF87GWxOncr61HZBZJgI9q/EmGIoVTZtEZ+XpKEcYuh54BiJUswp+SrGQ9pVsjIIBxstHMtX5VrJ2b0a33wqjl5SPDjHImF7LDjWWmG78GN5YF5WY5BZDxNmk/dHTaEHl5o3PkFRydu3rA7wbidY4mzAwfT2ZFRBlFWmelj6fMSs5T/x+PzovSOfs1CY97icPoAH3co8se5X7a5rytdDxyHwYqe4WqQAiUEQE59DKCW22iWaceg/OzD7HkLozdWkYtv3d0oZDR6ROk/9LlwX8Wck52dJqQUdcadHjJySA+taFDbJgh+Bzl+zkewp/gOEoaeXm8eVFzNrRvuJf4dENTxOC3Ab3v83gGIic3o+GlNYsoo8i8ziBOssERMmvWeR1CgEjU4Sl1Dd0Bzv1sy28anZH/YgboIZ7d/i6voRC+kjZqNiRP+UIgLQJy6GkRbEb80bVhmtagQbbm47DyplWRk8goR6HEGdpu48UuHQPaldfppju6UEiZ8CAZy0e3zKYZkZU3Oo/jsEyHAtPOmeBjsivlwhFaR2qg0wesHqKz94yj6wsZwT9I2f/28scydf/XHp00eheMM5E9i98c7sE8ft8ibq8uruBnpxLmzZfz7IzCsT9diR0udB3Cr1eaH23W1cwR0Bp6M7/BcZvHyPYMHPFprjyG4wlGtk86uliI4XICcKsAAAvdSURBVL4f/ucwiCcF5SZg4PtirP9cjC9OHjLvoNy3+HW28tD2FTY72CbPGFsejmAg66ZnWjy4bH27QafiChcLac8A8DnHK7OB9q1CvpdUOEq53+IkbF+B020oo8FPBFPOhZmqLDXaGWTkHaszGDTTOoTLg7j7it+MUiCIrqHzfPwH+P64mAz77C2nIU6j7FU8U0cFZbsQruT+fAb+XxTjb8w82vcGHdzXG7NO1VV9CMihV989K4vGUYOMgTuMk877WEp9FePsLKvW5cPaBnm2OeoSl5Y0ZEp7J87wbvgvDmT0oz6bsj5ixzvOPG90jlPJbHROe/K+GoYTeBM9bgGjWE2DP282DIxslD42FnOFFwKDT9O+IZ6atdATSZ/opRWLtiHTHrncFDbYXgDvN+ksbS7GlDaPUwO3IGM+ztvOPVhB/acHMjsSX8J76gPskKD66oHHdK4vW+lCoNERkENvdMgrr0Ic08lMNY6MaDYUozw0khabxNBNxVD+A6Oc1OvXOOYF6Occull+e8c8z6FTV0/SpzgFMbYvMcW62tFpQkb+J7BGG8o2WdQ12H5J5aLf58F9AKP355PKqBQ+cIjuwO7Ms3NFUv2Q1x7e2fyuTiqjFD57RrnHZ9MhtBH5KQHvCbynfhXxfypFlsoKgaZEIG/U0JSKqO6mQwDjm7c2nJEmHZFjRjn1hdN7Dgf4c0/QyALrrF8h3+p018JgDd7RiUOc+VycTKadX+TV0ElpFIeVuOExGLkPg2jLZ2IULakIMi/J6tOqcSrmWdlHx3EqZcM3EnjmwnMQCsmwe1goXWlCoKkQkENvKuQrpF5Gif0xTOM9dfZjyDal+B3wZF3a0K50r2xD0XD63wwpoykbpecu+1AKkcsC0oK9nTp1utOjE0dxWL1gnuEEgMuBFNhsQs5+J4twCuu4H/DoqotyH/J2toPN1qT40PgdHgD2adVLPbpoNOkud1+ozZag+8MujefseJZ7TnS0QiFQ6QhkOuqo9MZKvyMRYHRua7lhx45p6rmsXS46smS8FKa+v03Jb1hpDGJ3dqXbyNnexU51odNPWVd9CZm9TRCG115h+/ro0aP38KGUiaS/z6vgrnHjxu3y6MRRHEX0q2E3MEX790kF4iBsKtp1TtqxKeur0ParuovOTj8c+kRP8Vf69+/fb/DgwQe9tNjRYNnkZRhyGyAJr+RkvZs4Wc/vBBWUF90UV7BQjESeqyd5lka5ooza+xHf6GiFQqCSEQgNeSUrKd3Kg0AwOvyiJ30bBuxfPDpJ9GaYwo1EGMc5wfneSWSFPBjaOn5hRwO5XfkO+zQrQKcknNqnzGEOc3EOM+RPEin01TDkWPsSX+hmMwfbnAD0vQgn/1eOrqaQzo4tGYQ2hLbcmNSZW7vpKNmnVW9zGHCPj33ttde+5OhiYRYjdJNPnW/49UD7yzh+luJCoOIQCP8ZK04zKVR2BGx0iMGyDUi5C2N6k60lOjpJGBjl2z3e9zJKv8CjE0dx3OYMw84C8Sv4UMonCU9xQmnPQ7Rhg6PThA19NSyJbMPXcHa86NuVWZHLHV0tIbMlNiNia865izZtZ5RsrximupBhn1YNl23AJ9anVW2EnqrigJm6e/lyqH+7T/txymZSpy9TcSGQBgE59DToVTHvqlWrjkb9cOc4xml3jx49FmfRpA4dOuQZZWRm8mlVe4UNPZd6OvZjZObTrZhhSDWCdrKDWYU5jias76thXpF40QDnXa40TmMW09edHF0NITrndQahb07bGbR2I8P2GfzIYYDcvmwePNfR5Q7pNA736+B5syWAghe61RTMUKIQaCIE5NCbCPimrpYNR7aprIvTA8O1yNajHZ0m5P3e/8PYhY6WeH9GdOPSyPR4o9PpH3J5tOH5SZMmPezoNGHcr4YlqSPAeaHjBZ9eOK3pjq70MFgisL0R7trDUkK4HOISk4aMtu3TqoccP/jYPo+yX2wQtZmeoa4idHiOvRuvOjoakq8RehQU0U2KgBx6k8LfNJUzGuyKMQp3iRO3V3YyGdl6LboeuZkbZab0/4Dcn3v1hFHSb+WX2siCjzsYx8muY1rcTjPL7ELPWxAWLh/gtGJNLWemQApB6D4Tfbs6EdCLGFnvdnTaEFl/QsY9Tg51fYwNc2c5ulCYdg3d3sagU3VXRPayCC1SCFQ0AnLoFX17yqMcxu9ijORRnvQ7MKL1rhV65WJHg0+u3usxnIJRHu7RiaM411sLMO/s2rXrkgLpJSdh2CfB1MdjLPrVMK9c7GiBDWC9g3pjy2iKguvWrevCs+N/zW5fx44ds+4MtmKUfl2kc5b3elyWbWf2qDf7PB5B5kecXOq276Nbp0uXEKgaBOTQq+ZWZaMoo0/bBOe/JmVrwzdkI/0IKdf5KTiCTKZOmVZfg9wXfdm04fZix3T6ZYvF0bGGddQ850EHIq8dxfhLycNh5O01oG7bNV7R144dOy5CQdt/kbvA/c7x48eHu/ZdetqQDuazyLjfyQGb4TjexCfzOTku5P+gOxsqhyFzAbL/yM+Xbf8T09Ch6AZReLSG7gBVWBEI6D30irgNjacEo/MLMUTvdzViuO4utk7oyiUJGYWuZ73VjPLnAv6RGNCPUV+qD6agcx2j/UW040aTC32I38KgjlQBRv6zCBjohCA31lfDXPlSQhzGJtphswozjI/2/C3ruGdzwMmDpciJU5ZOyk3UFW7Ei8NjZY4++uizR40atdfidoDPhg0brrK4XWDzTvv27XP34N2UbP/SkZrPrMUYJxV8rKN1rqMbCOfT3nmRMu2QYa+h9eT9+R6RvBwZPEtf5hm1EXtJF/9ba6kzXGaKw4w+rzKbNaWhsuh7P7LfbqhcsXz+H08qlq+86kdADr3672HsFmA8WuNQ/VFgHaPETNeGo8pgIOdTr3Po9s64jdLPi5YrlbaT4Gpra/8Rvs7IX4WxqnfzUimy0S/vGFzTvxT+Ussa/jiCabShjfEG+GTu0BE9kDpKVa8VmwNDG8EBPhcg4AOekGVsgKx3F7hXLlGUjs1v6BA+CvMwE4D+46BPDJZz8mSC42GcXphG2d4Q9ot9ca9fo/B5OPNfxGGiPNX8BVPiQ+Lw+WWQsdGni8T7+XUVKaesFoyAptxb0M3HmZ+LUTjRNRljsopRYibvbDuZ0RBH+2vqecylEz8Xo9zH0UlDOwkOWbmd9BjzTNY6GZ0P9Y0y8p/EuD+RVMc4fOBvG8DCvQbUfzr36dQ4vI1ZBr1a09kIO4NgY57sO42gg9+hak290VF3TgU6RTUpdLElg2/QSRzA8xrLmaeoS6xCoGwIhL3vstUgwZWEwCAM4lqnEFOa33bxcobU+U3k++vSn4L+37R1ov8tOJk2rKn/Mq0s42d691QfH3b+l2062deXdvwz7XiPSyN+GvH/dLSF6Pamrxs8L/n5BeKb/fIF8mMl4eRyw146OwOR92eY7GfXepzfC+9Gy/eX0fjPmGq+gxrsIBu7etoZAbz6lzf9TKfuTZvyfrdI0b/WEdlDW3bQSXmR8HHu8zN0rEqaKg9qsLPf49RZTKFNhTK5vy+iX1rZhUQrTQgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIASEgBISAEBACQkAICAEhIASEgBAQAkJACAgBISAEhIAQEAJCQAgIARD4f8fcJLjgr+o5AAAAAElFTkSuQmCC'; +} diff --git a/src/app/main/directive/admin-image/admin-image.directive.spec.ts b/src/app/main/directive/admin-image/admin-image.directive.spec.ts new file mode 100644 index 0000000000..9cbcf8c5a5 --- /dev/null +++ b/src/app/main/directive/admin-image/admin-image.directive.spec.ts @@ -0,0 +1,71 @@ +/* eslint-disable max-len */ +import { AdminImageDirective } from './admin-image.directive'; +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +@Component({ + template: ` + ` +}) +class TestAdminImageComponent { + + img = 'http://dasch.swiss/content/images/2017/11/DaSCH_Logo_RGB.png'; + type = 'project'; + + constructor() { } +} + +describe('Directive: AdminImageDirective', () => { + let component: TestAdminImageComponent; + let fixture: ComponentFixture; + let imageEl: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + AdminImageDirective, + TestAdminImageComponent + ] + }); + + fixture = TestBed.createComponent(TestAdminImageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + imageEl = fixture.debugElement.query(By.css('img')); + + }); + + it('should create an instance', () => { + expect(component).toBeTruthy(); + }); + + it('should display the project logo of the DaSCH', () => { + expect(imageEl.nativeElement.src).toBe('http://dasch.swiss/content/images/2017/11/DaSCH_Logo_RGB.png'); + }); + + it('should display the default project logo if the image is null or undefined', () => { + component.img = null; + fixture.detectChanges(); + + expect(imageEl.nativeElement.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAuUlEQVR4Ae2XP8rCUBAHp5F4gPxBsA45mpUgXkt4Se4Rkc97fIQkhVZrK+JbxGwhujN9Bh77K8IPsWTPkSsXOnYkGLPmjNx5YoUhCX/Igx0LzNgiT9zwBhU1AxLxQEpGQCJOtFT653tEMQUgRxR7LVEjqhkABaLaEGVAVAM5BQ2iOhJFjPSAXeBVPKADfqa+Aw/4Dr53Bx6wD/iZfkZgQgwcidIiBgb0H5CZ/lOClmgYZzxOoMRxjLkBL3E6cltSSnYAAAAASUVORK5CYII='); + }); + + it('should detect the change of type to "user" and display the user logo', () => { + component.img = 'salsah@milchkannen.ch'; + component.type = 'user'; + fixture.detectChanges(); + + expect(imageEl.nativeElement.src).toBe('http://www.gravatar.com/avatar/dd74bbb106986f9ef074743e3c7fc555?d=mp&s=256'); + }); + + it('should display the default user logo if the image is null or undefined', () => { + component.img = undefined; + component.type = 'user'; + fixture.detectChanges(); + + expect(imageEl.nativeElement.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAA+klEQVR4Ae3SMUrDYBjG8X+n1kPoKg4l5g6Cu7jokaxbW5KhNxAcdZMiiOgB2iaXMChKO5jHrEr7Ncn7OSjf77/nScJLEAQNxKTkrKoyEiK82mGCvlWS0vP3+Hu0pqmviQnaUIIHMdpYSYRZihyNMcuRozlmK+Ro+QcGMuRohlmCHA0xiygdZ9qH3zzUEV70mKI13dEFXxMp5Y+fM6KLVxFj5iyrZgzpE/wre5xzyS0LCj6rChbcMOCMXYxiBuTIUcYFh7TQ4ZRnVLMnTujQwAGPqGEP7FPTMW+oRa8c1Xv7D9Sy9zpfcY0MXbFVgQy9sJWMNR8IA0EQfAFx/QsJxgdnsQAAAABJRU5ErkJggg=='); + }); + +}); diff --git a/src/app/main/directive/admin-image/admin-image.directive.ts b/src/app/main/directive/admin-image/admin-image.directive.ts new file mode 100644 index 0000000000..41cbeb7179 --- /dev/null +++ b/src/app/main/directive/admin-image/admin-image.directive.ts @@ -0,0 +1,87 @@ +import { Directive, OnChanges, Input, Renderer2, ElementRef } from '@angular/core'; +import { AdminImageConfig } from './admin-image.config'; +import { Md5 } from 'ts-md5'; + +@Directive({ + selector: '[appAdminImage]' +}) +export class AdminImageDirective implements OnChanges { + + /** + * @param {string} image + * + * source of the image; + * - in the case of user (gr)avatar it's the e-mail address, + * - in the case of project logo it's the image url + */ + @Input() image: string; + + /** + * @param {string} type + * + * type of image; you can use it with + * - project + * - user + */ + @Input() type: string; + + + /** + * @ignore + */ + source: string; + + + /** + * @ignore + */ + onError: string = AdminImageConfig.defaultNotFound; + + + /** + * @ignore + */ + constructor(private _renderer: Renderer2, + private _ele: ElementRef + ) { } + + /** + * @ignore + */ + ngOnChanges() { + + this.source = this.image; + + switch (this.type) { + + case 'user': + this.onError = AdminImageConfig.defaultUser; + + if (this.image === null || this.image === undefined) { + this.source = AdminImageConfig.defaultUser; + } else { + this.source = location.protocol + '//www.gravatar.com/avatar/' + Md5.hashStr(this.image) + '?d=mp&s=256'; + } + + break; + + case 'project': + this.onError = AdminImageConfig.defaultProject; + + if (this.image === null || this.image === undefined) { + + this.source = AdminImageConfig.defaultProject; + } + + break; + + default: + this.source = this.image; + } + + this._renderer.setAttribute(this._ele.nativeElement, 'src', this.source); + this._renderer.setAttribute(this._ele.nativeElement, 'onError', 'this.src=\'' + this.onError + '\''); + + } + +} diff --git a/src/app/main/directive/existing-name/existing-name.directive.spec.ts b/src/app/main/directive/existing-name/existing-name.directive.spec.ts new file mode 100644 index 0000000000..5dc660caf5 --- /dev/null +++ b/src/app/main/directive/existing-name/existing-name.directive.spec.ts @@ -0,0 +1,216 @@ + +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, FormControl, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ExistingNameDirective, existingNamesValidator } from './existing-name.directive'; + +@Component({ + template: ` +
+
+ + + + {{formErrors.name}} + + + + +
+
+ +
    +
  • {{n}}
  • +
+ ` +}) +class TestHostComponent implements OnInit { + + dataMock: string[] = [ + 'Ben', 'Tobias', 'André', 'Flavie', 'Ivan', 'Lucas', 'Mike' + ]; + + existingNames: [RegExp] = [ + new RegExp('user') + ]; + + form: FormGroup; + + formErrors = { + name: '' + }; + + validationMessages = { + name: { + required: 'A name is required', + existingName: 'This name exists already.' + } + }; + + constructor(private _formBuilder: FormBuilder) { } + + ngOnInit() { + // create a list of existing names + let i = 1; + for (const user of this.dataMock) { + this.existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)'); + + i++; + } + + // build form + this.form = this._formBuilder.group({ + name: new FormControl({ + value: '', disabled: false + }, [ + Validators.required, + existingNamesValidator(this.existingNames) + ]) + }); + + // detect changes in the form + this.form.valueChanges.subscribe( + data => this.onValueChanged(data) + ); + + this.onValueChanged(); + } + + onValueChanged(data?: any) { + + if (!this.form) { + return; + } + + // check if the form is valid + Object.keys(this.formErrors).map(field => { + this.formErrors[field] = ''; + const control = this.form.get(field); + if (control && control.dirty && !control.valid) { + const messages = this.validationMessages[field]; + Object.keys(control.errors).map(key => { + this.formErrors[field] += messages[key] + ' '; + }); + } + }); + } +} + +describe('ExistingNameDirective', () => { + + let component: TestHostComponent; + let fixture: ComponentFixture; + const existingNamesList: string[] = [ + 'Ben', 'Tobias', 'André', 'Flavie', 'Ivan', 'Lucas', 'Mike' + ]; + const existingNames: [RegExp] = [ + new RegExp('user') + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, FormsModule, + MatFormFieldModule, MatInputModule, BrowserAnimationsModule + ], + declarations: [ + ExistingNameDirective, + TestHostComponent + ] + }); + + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + component.ngOnInit(); + }); + + it('should create an instance', () => { + expect(component).toBeTruthy(); + }); + + it('form invalid when empty', () => { + expect(component.form.valid).toBeFalsy(); + }); + + it('name input field validity', () => { + const name = component.form.controls.name; + expect(name.valid).toBeFalsy(); + }); + + it('should recognize the new name "Benjamin" and validate the form', () => { + expect(component.dataMock).toEqual(existingNamesList); + expect(component.form.valid).toBeFalsy(); + + let i = 1; + for (const user of existingNamesList) { + existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)'); + + i++; + } + + expect(component.existingNames).toEqual(existingNames); + + fixture.detectChanges(); + + const name = component.form.controls.name; + + let errors = {}; + errors = name.errors || {}; + expect(name.valid).toBeFalsy(); + + // name field is required + expect(errors['required']).toBeTruthy(); + expect(existingNamesValidator(existingNames)); + + // set a new name + name.setValue('Benjamin'); + fixture.detectChanges(); + + errors = name.errors || {}; + + expect(component.form.valid).toBeTruthy(); + expect(errors['required']).toBeFalsy(); + expect(existingNamesValidator(existingNames)).toBeTruthy(); + expect(component.form.controls.name.errors).toEqual(null); + }); + + it('should recognize the existing name "Ben" and invalid the form', () => { + fixture.detectChanges(); + expect(component.dataMock).toEqual(existingNamesList); + expect(component.form.valid).toBeFalsy(); + + let i = 1; + for (const user of existingNamesList) { + existingNames[i] = new RegExp('(?:^|W)' + user.toLowerCase() + '(?:$|W)'); + + i++; + } + + expect(component.existingNames).toEqual(existingNames); + + let errors = {}; + const name = component.form.controls.name; + expect(name.valid).toBeFalsy(); + + // name field is required + errors = name.errors || {}; + expect(errors['required']).toBeTruthy(); + expect(existingNamesValidator(existingNames)); + + // set an existing name + name.setValue('Ben'); + fixture.detectChanges(); + + errors = name.errors || {}; + expect(component.form.valid).toBeFalsy(); + expect(errors['required']).toBeFalsy(); + expect(existingNamesValidator(existingNames)).toBeTruthy(); + expect(component.form.controls.name.errors.existingName.name).toEqual('ben'); + }); + +}); diff --git a/src/app/main/directive/existing-name/existing-name.directive.ts b/src/app/main/directive/existing-name/existing-name.directive.ts new file mode 100644 index 0000000000..20eef3cc4b --- /dev/null +++ b/src/app/main/directive/existing-name/existing-name.directive.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { Directive, OnChanges, Input, SimpleChanges } from '@angular/core'; +import { Validators, AbstractControl, ValidatorFn } from '@angular/forms'; + +@Directive({ + selector: '[appExistingNames]' +}) +export class ExistingNameDirective implements Validators, OnChanges { + + /** + * @ignore + */ + @Input() existingName: string; + + /** + * @ignore + */ + private _valFn = Validators.nullValidator; + + /** + * @ignore + * @param changes + */ + ngOnChanges(changes: SimpleChanges): void { + const change = changes['existingName']; + if (change) { + const val: string | RegExp = change.currentValue; + const re = val instanceof RegExp ? val : new RegExp(val); + this._valFn = existingNameValidator(re); + } else { + this._valFn = Validators.nullValidator; + } + } + + /** + * @ignore + * @param control + */ + validate(control: AbstractControl): { [key: string]: any } { + return this._valFn(control); + } +} + +/** + * validation of existing name value. String method (only one value); + * Use it in a "formbuilder" group as a validator property + * + * @param {RegExp} valRegexp Only one regular expression value + * @returns ValidatorFn + */ +export function existingNameValidator(valRegexp: RegExp): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + let name; + + if (control.value) { + name = control.value.toLowerCase(); + } + + const no = valRegexp.test(name); + return no ? { 'existingName': { name } } : null; + }; +} + +/** + * validation of existing name values. Array method (list of values) + * Use it in a "formbuilder" group as a validator property + * + * @param {RegExp} valArrayRegexp List of regular expression values + * @returns ValidatorFn + */ +export function existingNamesValidator(valArrayRegexp: [RegExp]): ValidatorFn { + + return (control: AbstractControl): { [key: string]: any } => { + + let name; + + if (control.value) { + name = control.value.toLowerCase(); + } + + let no; + for (const existing of valArrayRegexp) { + no = existing.test(name); + if (no) { + // console.log(no); + return no ? { 'existingName': { name } } : null; + } + } + return no ? { 'existingName': { name } } : null; + }; +} + +/** + * @ignore + * + * @param {RegExp} pattern + * @param {string} regType + * @returns ValidatorFn + */ +export function notAllowed(pattern: RegExp, regType: string): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + let name; + + if (control.value) { + name = control.value.toLowerCase(); + } + + const no = pattern.test(name); + return no ? { regType: { name } } : null; + }; +} + diff --git a/src/app/main/directive/gnd/gnd.directive.spec.ts b/src/app/main/directive/gnd/gnd.directive.spec.ts new file mode 100644 index 0000000000..f04e2e849e --- /dev/null +++ b/src/app/main/directive/gnd/gnd.directive.spec.ts @@ -0,0 +1,145 @@ +import { Component, DebugElement, ElementRef, OnInit } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { GndDirective } from './gnd.directive'; + +/** + * test component for a GND/IAF identifier. + */ +@Component({ + template: '' +}) +class TestGnd1Component { } + +/** + * test component for a VIAF identifier. + */ +@Component({ + template: '' +}) +class TestGnd2Component { } + +/** + * test component for normal text. + */ +@Component({ + template: '' +}) +class TestGnd3Component { } + +/** + * test component for long normal text. + */ +@Component({ + template: `` +}) +class TestGnd4Component { } + +/** + * test component with an updated text. + */ +@Component({ + template: '' +}) +class TestGnd5Component implements OnInit { + + gndValue; + + ngOnInit() { + this.gndValue = 'initial text'; + } +} + +class MockElementRef implements ElementRef { + nativeElement = {}; +} + + +describe('GndDirective', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TestGnd1Component, TestGnd2Component, TestGnd3Component, TestGnd4Component, TestGnd5Component, GndDirective], + providers: [ + { provide: ElementRef, useClass: MockElementRef } + ] + }).compileComponents(); + + })); + + it('should create an instance', () => { + + const directive = new GndDirective(new MockElementRef()); + expect(directive).toBeTruthy(); + + }); + + it('should transform a GND/IAF identifier to a hyperlink pointing to the resolver', () => { + + const fixture: ComponentFixture = TestBed.createComponent(TestGnd1Component); + fixture.detectChanges(); + + const spanEle: DebugElement = fixture.debugElement.query(By.css('span')); + + expect(spanEle.nativeElement.innerHTML).toEqual('(DE-588)118696149'); + + }); + + it('should transform a VIAF identifier to a hyperlink pointing to the resolver', () => { + + const fixture: ComponentFixture = TestBed.createComponent(TestGnd2Component); + fixture.detectChanges(); + + const spanEle: DebugElement = fixture.debugElement.query(By.css('span')); + + expect(spanEle.nativeElement.innerHTML).toEqual('(VIAF)22936072'); + + }); + + it('should not transform normal text', () => { + + const fixture: ComponentFixture = TestBed.createComponent(TestGnd3Component); + fixture.detectChanges(); + + const spanEle: DebugElement = fixture.debugElement.query(By.css('span')); + + expect(spanEle.nativeElement.innerHTML).toEqual('normal text'); + + }); + + it('should not transform long normal text', () => { + + const fixture: ComponentFixture = TestBed.createComponent(TestGnd4Component); + fixture.detectChanges(); + + const spanEle: DebugElement = fixture.debugElement.query(By.css('span')); + + expect(spanEle.nativeElement.innerHTML).toEqual('normal text that is quite long and will not even be looked at because it cannot possibly be a GND/IAF or VIAF identifier'); + + }); + + it('should be equal to the updated text', () => { + + const fixture: ComponentFixture = TestBed.createComponent(TestGnd5Component); + fixture.detectChanges(); + + const spanEle: DebugElement = fixture.debugElement.query(By.css('span')); + + expect(spanEle.nativeElement.innerHTML).toEqual('initial text'); + + + fixture.componentInstance.gndValue = 'updated text'; + fixture.detectChanges(); + + expect(spanEle.nativeElement.innerHTML).toEqual('updated text'); + + }); + + +}); + + + + + diff --git a/src/app/main/directive/gnd/gnd.directive.ts b/src/app/main/directive/gnd/gnd.directive.ts new file mode 100644 index 0000000000..7f4194f941 --- /dev/null +++ b/src/app/main/directive/gnd/gnd.directive.ts @@ -0,0 +1,57 @@ +import { Directive, ElementRef, Input, OnChanges } from '@angular/core'; + +export class GNDConstants { + public static GND_PREFIX = '(DE-588)'; + public static GND_RESOLVER = 'http://d-nb.info/gnd/'; + + public static VIAF_PREFIX = '(VIAF)'; + public static VIAF_RESOLVER = 'https://viaf.org/viaf/'; +} + +/** + * this directive renders a GND/IAF or a VIAF identifier as a link to the respective resolver. + */ +@Directive({ + selector: '[appGnd]' +}) +export class GndDirective implements OnChanges { + + @Input() + set gnd(value: string) { + this._gnd = value; + } + + get gnd() { + return this._gnd; + } + + + // the GND identifier to be rendered + private _gnd: string; + + constructor(private _ele: ElementRef) { + + } + + ngOnChanges() { + if (this._gnd.length < 30) { + + if (this._gnd.indexOf(GNDConstants.GND_PREFIX) === 0) { + // gnd/iaf identifier + this._ele.nativeElement.innerHTML = `${this._gnd}`; + } else if (this._gnd.indexOf(GNDConstants.VIAF_PREFIX) === 0) { + // viaf identifier + this._ele.nativeElement.innerHTML = `${this._gnd}`; + } else { + // no identifier, leave unchanged + this._ele.nativeElement.innerHTML = this._gnd; + } + + } else { + // no identifier, leave unchanged + this._ele.nativeElement.innerHTML = this._gnd; + } + + } + +} diff --git a/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts b/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts new file mode 100644 index 0000000000..862c0bb6e7 --- /dev/null +++ b/src/app/main/pipes/formatting/formatted-boolean.pipe.spec.ts @@ -0,0 +1,45 @@ +import { FormattedBooleanPipe } from './formatted-boolean.pipe'; + +describe('FormattedBooleanPipe', () => { + let pipe: FormattedBooleanPipe; + + beforeEach(() => { + pipe = new FormattedBooleanPipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return "true" (no format specified)', () => { + const myBoolean = true; + + const convertedBoolean = pipe.transform(myBoolean); + + expect(convertedBoolean).toEqual('true'); + }); + + it('should return "false" (format specified)', () => { + const myBoolean = false; + + const convertedBoolean = pipe.transform(myBoolean, 'true-false'); + + expect(convertedBoolean).toEqual('false'); + }); + + it('should return "yes"', () => { + const myBoolean = true; + + const convertedBoolean = pipe.transform(myBoolean, 'yes-no'); + + expect(convertedBoolean).toEqual('yes'); + }); + + it('should return "off"', () => { + const myBoolean = false; + + const convertedBoolean = pipe.transform(myBoolean, 'on-off'); + + expect(convertedBoolean).toEqual('off'); + }); +}); diff --git a/src/app/main/pipes/formatting/formatted-boolean.pipe.ts b/src/app/main/pipes/formatting/formatted-boolean.pipe.ts new file mode 100644 index 0000000000..0c48c9b1e5 --- /dev/null +++ b/src/app/main/pipes/formatting/formatted-boolean.pipe.ts @@ -0,0 +1,32 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +interface DisplayTypes { + value: string; + viewValue: string; +} + +@Pipe({ + name: 'formattedBoolean' +}) +export class FormattedBooleanPipe implements PipeTransform { + + displayTypes: DisplayTypes[] = [ + { value: 'true-false', viewValue: 'True/False' }, + { value: 'yes-no', viewValue: 'Yes/No' }, + { value: 'on-off', viewValue: 'On/Off' } + ]; + + transform(value: boolean, format?: string): string { + switch (format) { + case 'true-false': + return value ? 'true' : 'false'; + case 'yes-no': + return value ? 'yes' : 'no'; + case 'on-off': + return value ? 'on' : 'off'; + default: + return value ? 'true' : 'false'; + } + } + +} diff --git a/src/app/main/pipes/formatting/knoradate.pipe.spec.ts b/src/app/main/pipes/formatting/knoradate.pipe.spec.ts new file mode 100644 index 0000000000..4fafa0eb7e --- /dev/null +++ b/src/app/main/pipes/formatting/knoradate.pipe.spec.ts @@ -0,0 +1,98 @@ +import { KnoraDate } from '@dasch-swiss/dsp-js'; +import { KnoraDatePipe } from './knoradate.pipe'; + +describe('KnoradatePipe', () => { + let pipe: KnoraDatePipe; + + beforeEach(() => { + pipe = new KnoraDatePipe(); + expect(pipe).toBeTruthy(); + }); + + it('should return a date string', () => { + const date = new KnoraDate('GREGORIAN', 'AD', 1993, 10, 10); + + const convertedDate = pipe.transform(date); + + expect(convertedDate).toEqual('10.10.1993'); + }); + + it('should return the correct format for a date with day precision depending on the format provided', () => { + const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7, 4); + + let convertedDate = pipe.transform(date, 'dd.MM.YYYY'); + + expect(convertedDate).toEqual('04.07.1776'); + + convertedDate = pipe.transform(date, 'dd-MM-YYYY'); + + expect(convertedDate).toEqual('04-07-1776'); + + convertedDate = pipe.transform(date, 'MM/dd/YYYY'); + + expect(convertedDate).toEqual('07/04/1776'); + + // should default to dd.MM.YYYY in the event of an invalid format + convertedDate = pipe.transform(date, 'invalid format'); + + expect(convertedDate).toEqual('04.07.1776'); + }); + + it ('should return a string with the desired display options', () => { + const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7, 4); + + let dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'era'); + + expect(dateWithDisplayOptions).toEqual('04.07.1776 AD'); + + dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'calendar'); + + expect(dateWithDisplayOptions).toEqual('04.07.1776 GREGORIAN'); + + dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'all'); + + expect(dateWithDisplayOptions).toEqual('04.07.1776 AD GREGORIAN'); + }); + + it ('should return a string with the desired display options for a date without era', () => { + const date = new KnoraDate('ISLAMIC', 'noEra', 1441, 7, 4); + + let dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'era'); + + expect(dateWithDisplayOptions).toEqual('04.07.1441'); + + dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'calendar'); + + expect(dateWithDisplayOptions).toEqual('04.07.1441 ISLAMIC'); + + dateWithDisplayOptions = pipe.transform(date, 'dd.MM.YYYY', 'all'); + + expect(dateWithDisplayOptions).toEqual('04.07.1441 ISLAMIC'); + }); + + it ('should return a string with only the month and the year', () => { + const date = new KnoraDate('GREGORIAN', 'AD', 1776, 7); + + const convertedDate = pipe.transform(date, 'dd.MM.YYYY'); + + expect(convertedDate).toEqual('07.1776'); + }); + + it ('should return a string with only the year', () => { + const date = new KnoraDate('GREGORIAN', 'AD', 1776); + + const convertedDate = pipe.transform(date, 'dd.MM.YYYY'); + + expect(convertedDate).toEqual('1776'); + }); + + it('should return a number of two digits', () => { + let num = pipe.leftPadding(7); + + expect(num).toEqual('07'); + + num = pipe.leftPadding(12); + + expect(num).toEqual('12'); + }); +}); diff --git a/src/app/main/pipes/formatting/knoradate.pipe.ts b/src/app/main/pipes/formatting/knoradate.pipe.ts new file mode 100644 index 0000000000..ac751d81f8 --- /dev/null +++ b/src/app/main/pipes/formatting/knoradate.pipe.ts @@ -0,0 +1,82 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { KnoraDate } from '@dasch-swiss/dsp-js'; + +@Pipe({ + name: 'knoraDate' +}) +export class KnoraDatePipe implements PipeTransform { + + transform(date: KnoraDate, format?: string, displayOptions?: 'era' | 'calendar' | 'all'): string { + if (!(date instanceof KnoraDate)) { + console.error('Non-KnoraDate provided. Expected a valid KnoraDate'); + return ''; + } + + const formattedString = this.getFormattedString(date, format); + + if (displayOptions) { + return this.addDisplayOptions(date, formattedString, displayOptions); + } else { + return formattedString; + } + } + + // ensures that day and month are always two digits + leftPadding(value: number): string { + if (value !== undefined) { + return ('0' + value).slice(-2); + } else { + return null; + } + } + + // add the era, calendar, or both to the result returned by the pipe + addDisplayOptions(date: KnoraDate, value: string, options: string): string { + switch (options) { + case 'era': + return value + (date.era !== 'noEra' ? ' ' + date.era : ''); + case 'calendar': + return value + ' ' + date.calendar; + case 'all': + return value + (date.era !== 'noEra' ? ' ' + date.era : '') + ' ' + date.calendar; + } + } + + getFormattedString(date: KnoraDate, format: string): string { + switch (format) { + case 'dd.MM.YYYY': + if (date.precision === 2) { + return `${this.leftPadding(date.day)}.${this.leftPadding(date.month)}.${date.year}`; + } else if (date.precision === 1) { + return `${this.leftPadding(date.month)}.${date.year}`; + } else { + return `${date.year}`; + } + case 'dd-MM-YYYY': + if (date.precision === 2) { + return `${this.leftPadding(date.day)}-${this.leftPadding(date.month)}-${date.year}`; + } else if (date.precision === 1) { + return `${this.leftPadding(date.month)}-${date.year}`; + } else { + return `${date.year}`; + } + case 'MM/dd/YYYY': + if (date.precision === 2) { + return `${this.leftPadding(date.month)}/${this.leftPadding(date.day)}/${date.year}`; + } else if (date.precision === 1) { + return `${this.leftPadding(date.month)}/${date.year}`; + } else { + return `${date.year}`; + } + default: + if (date.precision === 2) { + return `${this.leftPadding(date.day)}.${this.leftPadding(date.month)}.${date.year}`; + } else if (date.precision === 1) { + return `${this.leftPadding(date.month)}.${date.year}`; + } else { + return `${date.year}`; + } + } + } + +} diff --git a/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts b/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts new file mode 100644 index 0000000000..dca2367e44 --- /dev/null +++ b/src/app/main/pipes/string-transformation/linkify.pipe.spec.ts @@ -0,0 +1,39 @@ +/* eslint-disable max-len */ +import { LinkifyPipe } from './linkify.pipe'; + +describe('LinkifyPipe', () => { + + let pipe: LinkifyPipe; + + beforeEach(() => { + pipe = new LinkifyPipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should recognize the url without protocol but with hashtag at the end', () => { + const text = 'You can visit the app on app.dasch.swiss/#'; + const linkifiedSnippet = pipe.transform(text); + expect(linkifiedSnippet).toEqual('You can visit the app on app.dasch.swiss/#'); + }); + + it('should recognize the url with protocol followed by full stop', () => { + const text = 'You can visit the app on https://app.dasch.swiss.'; + const linkifiedSnippet = pipe.transform(text); + expect(linkifiedSnippet).toEqual('You can visit the app on https://app.dasch.swiss.'); + }); + + it('should recognize both urls in the example text', () => { + const text = 'You can visit the app on https://app.dasch.swiss and the documentation on docs.dasch.swiss.'; + const linkifiedSnippet = pipe.transform(text); + expect(linkifiedSnippet).toEqual('You can visit the app on https://app.dasch.swiss and the documentation on docs.dasch.swiss.'); + }); + + it('should keep the spaces after a full stop or after a comma', () => { + const text = 'This is just a title. And it could have an URL, but it doesn\'t have one. '; + const linkifiedSnippet = pipe.transform(text); + expect(linkifiedSnippet).toEqual('This is just a title. And it could have an URL, but it doesn\'t have one.'); + }); +}); diff --git a/src/app/main/pipes/string-transformation/linkify.pipe.ts b/src/app/main/pipes/string-transformation/linkify.pipe.ts new file mode 100644 index 0000000000..b27412e783 --- /dev/null +++ b/src/app/main/pipes/string-transformation/linkify.pipe.ts @@ -0,0 +1,57 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * this pipe analyses a string and converts any url into a href tag + * + */ +@Pipe({ + name: 'appLinkify' +}) +export class LinkifyPipe implements PipeTransform { + + transform(value: string): string { + let stylizedText = ''; + if (value && value.length > 0) { + for (let str of value.split(' ')) { + // if string/url ends with a full stop '.' or colon ':' or comma ',' or semicolon ';' the pipe will not recognize the url + const lastChar = str.substring(str.length - 1); + const endsWithFullStop = (lastChar === '.' || lastChar === ':' || lastChar === ',' || lastChar === ';'); + let end = ' '; + if (endsWithFullStop) { + str = str.slice(0, -1); + end = lastChar + ' '; + } + if (this._recognizeUrl(str)) { + const url = this._setProtocol(str); + stylizedText += `${str}${end}`; + } else { + stylizedText += str + end; + } + } + return stylizedText.trim(); + } else { + return value; + } + } + + private _recognizeUrl(str: string): boolean { + const pattern = new RegExp( + '^(https?:\\/\\/)?' + // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))' + // oR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string + '(\\#[-a-z\\d_]*)?$', + 'i' + ); // fragment locator + return pattern.test(str); + } + + private _setProtocol(url: string): string { + if (!/^(?:f|ht)tps?\:\/\//.test(url)) { + url = 'http://' + url; + } + return url; + } + +} diff --git a/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts new file mode 100644 index 0000000000..514f191a3c --- /dev/null +++ b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.spec.ts @@ -0,0 +1,131 @@ +import { waitForAsync, TestBed } from '@angular/core/testing'; +import { StringLiteral } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens'; +import { Session, SessionService } from '../../services/session.service'; +import { StringifyStringLiteralPipe } from './stringify-string-literal.pipe'; + +describe('StringifyStringLiteralPipe', () => { + let pipe: StringifyStringLiteralPipe; + let labels: StringLiteral[]; + let service: SessionService; + + beforeEach(waitForAsync(() => { + + // empty spy object to use in the providers for the SessionService injection + const dspConnSpy = { }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: DspApiConnectionToken, + useValue: dspConnSpy + }, + SessionService + ] + }); + + service = TestBed.inject(SessionService); + pipe = new StringifyStringLiteralPipe(service); + })); + + beforeEach(() => { + + labels = [ + { + value: 'Welt', + language: 'de' + }, + { + value: 'World', + language: 'en' + }, + { + value: 'Monde', + language: 'fr' + }, + { + value: 'Mondo', + language: 'it' + }, + ]; + }); + + // mock localStorage + beforeEach(() => { + let store = {}; + + spyOn(localStorage, 'getItem').and.callFake( + (key: string): string => store[key] || null + ); + spyOn(localStorage, 'removeItem').and.callFake( + (key: string): void => { + delete store[key]; + } + ); + spyOn(localStorage, 'setItem').and.callFake( + (key: string, value: string): void => { + store[key] = value; + } + ); + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return a string in English', () => { + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + // since no argument is provided, the pipe should use the language stored in the session + const myString = pipe.transform(labels); + expect(myString).toEqual('World'); + + // remove session + localStorage.removeItem('session'); + }); + + it('should return a string in German', () => { + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'de', + sysAdmin: false, + projectAdmin: [] + } + }; + + // store session in localStorage + localStorage.setItem('session', JSON.stringify(session)); + + // since no argument is provided, the pipe should use the language stored in the session + const myString = pipe.transform(labels); + expect(myString).toEqual('Welt'); + + // remove session + localStorage.removeItem('session'); + }); + + it('should return a string with all languages of which the StringLiteral array contains', () => { + + // since no argument is provided, the pipe should use the language stored in the session + const myString = pipe.transform(labels, 'all'); + expect(myString).toEqual('Welt (de) / World (en) / Monde (fr) / Mondo (it)'); + }); +}); diff --git a/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts new file mode 100644 index 0000000000..e983a938af --- /dev/null +++ b/src/app/main/pipes/string-transformation/stringify-string-literal.pipe.ts @@ -0,0 +1,64 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { StringLiteral } from '@dasch-swiss/dsp-js'; +import { SessionService } from '../../services/session.service'; + +/** + * this pipe stringifies an array of StringLiterals. + * With the parameter 'all', the pipe concats all values and appends the corresponding language in brackets. + * + * Otherwise the pipe displays the value corresponding to the default language which + * comes from the user profile if a user is logged in or from the browser if the user is not logged in. + * + * With the predefined language, the pipe checks if a value exists in the array, otherwise it shows the first value. + */ +@Pipe({ + name: 'appStringifyStringLiteral' +}) +export class StringifyStringLiteralPipe implements PipeTransform { + + constructor(private _sessionService: SessionService) { } + + transform(value: StringLiteral[], args?: string): string { + let stringified = ''; + + let language: string; + + if (!value || !value.length) { + return; + } + + if (args === 'all') { + // show all values + let i = 0; + for (const sl of value) { + const delimiter = (i > 0 ? ' / ' : ''); + stringified += delimiter + sl.value + ' (' + sl.language + ')'; + + i++; + } + return stringified; + } else { + // show only one value, depending on default language + // the language is defined in user profile if a user is logged-in + // otherwise it takes the language from browser + if (this._sessionService.getSession() !== null) { + // get language from the logged-in user profile data + language = this._sessionService.getSession().user.lang; + } else { + // get default language from browser + language = navigator.language.substr(0, 2); + } + // does the defined language exists and does it have a value? + const index = value.findIndex(i => i.language === language); + + if (value[index] && value[index].value.length > 0) { + return value[index].value; + } else { + return value[0].value; + } + + } + + } + +} diff --git a/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts b/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts new file mode 100644 index 0000000000..9c39bed62a --- /dev/null +++ b/src/app/main/pipes/string-transformation/truncate.pipe.spec.ts @@ -0,0 +1,62 @@ +import { TruncatePipe } from './truncate.pipe'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +describe('TruncatePipe', () => { + let pipe: TruncatePipe; + let snippet: string; + + beforeEach(() => { + pipe = new TruncatePipe(); + snippet = 'The quick brown fox jumps over the lazy dog.'; + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should truncate after 15 characters', () => { + const truncatedSnippet = pipe.transform(snippet, 15); + expect(truncatedSnippet).toEqual('The quick brown...'); + }); + + it('should truncate after 19 characters and add an exclamation mark at the end', () => { + const truncatedSnippet = pipe.transform(snippet, 19, '!'); + expect(truncatedSnippet).toEqual('The quick brown fox!'); + }); + + it('should truncate after 20 characters by default', () => { + const truncatedSnippet = pipe.transform(snippet); + expect(truncatedSnippet).toEqual('The quick brown fox ...'); + }); + + it('should support the limit argument in the template', () => { + @Component({ + template: '{{ text | appTruncate:4 }}', + }) + class AppComponent { + text = 'This is my string'; + } + + TestBed.configureTestingModule({ declarations: [AppComponent, TruncatePipe] }); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('This...'); + }); + + it('should support the trail argument in the template', () => { + @Component({ + template: '{{ text | appTruncate:7:\'!!\' }}', + }) + class AppComponent { + text = 'This is my string'; + } + + TestBed.configureTestingModule({ declarations: [AppComponent, TruncatePipe] }); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('This is!!'); + }); +}); diff --git a/src/app/main/pipes/string-transformation/truncate.pipe.ts b/src/app/main/pipes/string-transformation/truncate.pipe.ts new file mode 100644 index 0000000000..4118914ab6 --- /dev/null +++ b/src/app/main/pipes/string-transformation/truncate.pipe.ts @@ -0,0 +1,45 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * this pipe can be used to shorten long text by a defined length. + * + * In markup: + * + * {{ str | appTruncate:24 }} + * + * or + * + * {{ str | appTruncate:24:'...' }} + * + * The first optional parameter defines the length where to truncate the string. + * The second optional parameter defines the characters to append to the shortened string. Default is '...'. + * + * The advantage of this pipe over the default Angular slice pipe is the simplicity of adding + * additional characters at the end of the shortened string. + * The same construct with Angular slice pipe looks as follow: `{{ (str.length>24)? (str | slice:0:24)+'...':(str) }}`. + * + */ +@Pipe({ + name: 'appTruncate' +}) +export class TruncatePipe implements PipeTransform { + + defaultLimit = 20; + defaultTrail = '...'; + + transform(value: string, limit?: number, trail?: string): string { + + if (typeof value !== 'string' || value.length === 0) { + return ''; + } + + // if a custom limit was provided, use that. Otherwise, use the default limit of 20 characters + const limitSetting: number = limit !== undefined ? limit : this.defaultLimit; + + // if a custom trail was provided, use that. Otherwise, use the default trail of '...' + const trailSetting: string = trail !== undefined ? trail : this.defaultTrail; + + return value.length > limitSetting ? value.substring(0, limitSetting) + trailSetting : value; + } + +} diff --git a/src/app/main/services/notification.service.spec.ts b/src/app/main/services/notification.service.spec.ts new file mode 100644 index 0000000000..ea2b7b5699 --- /dev/null +++ b/src/app/main/services/notification.service.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { NotificationService } from './notification.service'; + +describe('NotificationService', () => { + let service: NotificationService; + const mockNotificationService = jasmine.createSpyObj('NotificationService', ['openSnackBar']); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: NotificationService, useValue: mockNotificationService } + ] + }); + service = TestBed.inject(NotificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('openSnackBar', () => { + it('should open the snack bar', () => { + const arg = 'test'; + mockNotificationService.openSnackBar.and.callThrough(); + service.openSnackBar(arg); + expect(service.openSnackBar).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/main/services/notification.service.ts b/src/app/main/services/notification.service.ts new file mode 100644 index 0000000000..c361b653dc --- /dev/null +++ b/src/app/main/services/notification.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ApiResponseError } from '@dasch-swiss/dsp-js'; +import { StatusMsg } from 'src/assets/http/statusMsg'; + +@Injectable({ + providedIn: 'root' +}) +export class NotificationService { + + constructor( + private _snackBar: MatSnackBar, + private _statusMsg: StatusMsg + ) { } + + // todo: maybe we can add more parameters like: + // action: string = 'x', duration: number = 4200 + // and / or type: 'note' | 'warning' | 'error' | 'success'; which can be used for the panelClass + openSnackBar(notification: string | ApiResponseError): void { + const duration = 5000; + let message: string; + let panelClass: string; + + if (notification instanceof ApiResponseError) { + const status = (notification.status === 0 ? 503 : notification.status); + const defaultStatusMsg = this._statusMsg.default; + message = `${defaultStatusMsg[status].message} (${status}): ${defaultStatusMsg[status].description}`; + panelClass = 'error'; + } else { + message = notification; + panelClass = 'success'; + } + + this._snackBar.open(message, 'x', { + duration, + horizontalPosition: 'center', + verticalPosition: 'top', + panelClass + }); + } +} diff --git a/src/app/main/services/sorting.service.spec.ts b/src/app/main/services/sorting.service.spec.ts new file mode 100644 index 0000000000..6b3c6be3f9 --- /dev/null +++ b/src/app/main/services/sorting.service.spec.ts @@ -0,0 +1,165 @@ +import { TestBed } from '@angular/core/testing'; + +import { SortingService } from './sorting.service'; +import { ReadProject } from '@dasch-swiss/dsp-js'; + +describe('SortingService', () => { + let service: SortingService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SortingService + ] + }); + + service = TestBed.inject(SortingService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('reverseArray', () => { + + let data: string[]; + + beforeEach(() => { + data = ['Bernouilli', 'Euler', 'Goldbach', 'Hermann']; + }); + + it('should reverse an array', () => { + expect(service.reverseArray(data)).toEqual(['Hermann', 'Goldbach', 'Euler', 'Bernouilli']); + }); + }); + + describe('sortByAlphabetical', () => { + + let data: + { + firstname: string; + lastname: string; + creator: string; + }[]; + + beforeEach(() => { + data = [ + { + firstname: 'Gaston', + lastname: 'Lagaffe', + creator: 'André Franquin' + }, + { + firstname: 'Mickey', + lastname: 'Mouse', + creator: 'Walt Disney' + }, + { + firstname: 'Gyro', + lastname: 'Gearloose', + creator: 'Carl Barks' + }, + { + firstname: 'Charlie', + lastname: 'Brown', + creator: 'Charles M. Schulz' + } + ]; + }); + + it('should return an array sorted by creator', () => { + const sorted = service.keySortByAlphabetical(data, 'creator'); + expect(sorted).toEqual( + [ + { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' }, + { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' }, + { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' }, + { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' } + ]); + }); + + it('should return an array sorted by firstname', () => { + const sorted = service.keySortByAlphabetical(data, 'firstname'); + expect(sorted).toEqual( + [ + { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' }, + { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' }, + { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' }, + { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' } + ]); + }); + + it('should return an array sorted by lastname', () => { + const sorted = service.keySortByAlphabetical(data, 'lastname'); + expect(sorted).toEqual( + [ + { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' }, + { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' }, + { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' }, + { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' } + ]); + }); + + it('should return an array sorted by lastname reversed', () => { + const sorted = service.keySortByAlphabetical(data, 'lastname', true); + expect(sorted).toEqual( + [ + { firstname: 'Mickey', lastname: 'Mouse', creator: 'Walt Disney' }, + { firstname: 'Gaston', lastname: 'Lagaffe', creator: 'André Franquin' }, + { firstname: 'Gyro', lastname: 'Gearloose', creator: 'Carl Barks' }, + { firstname: 'Charlie', lastname: 'Brown', creator: 'Charles M. Schulz' } + ]); + }); + + }); + + describe('Sort an array of ReadProject', () => { + let project1: ReadProject; + let project2: ReadProject; + let project3: ReadProject; + let project4: ReadProject; + let projects: ReadProject[]; + + beforeEach(() => { + project1 = new ReadProject(); + project1.id = '1'; + project1.longname = 'a'; + + project2 = new ReadProject(); + project2.id = '2'; + project2.longname = 'b'; + + project3 = new ReadProject(); + project3.id = '3'; + project3.longname = 'c'; + + project4 = new ReadProject(); + project4.id = '23'; + project4.longname = 'z'; + + projects = [project4, project2, project3, project1]; + }); + + it('should sort an array of ReadProject by "longname"', () => { + + const sorted = service.keySortByAlphabetical(projects, 'longname'); + + expect(sorted).toEqual( + [project1, project2, project3, project4] + ); + + }); + + it('should sort an array of ReadProject by "longname" reversed', () => { + + const sorted = service.keySortByAlphabetical(projects, 'longname', true); + + expect(sorted).toEqual( + [project4, project3, project2, project1] + ); + + }); + + }); + +}); diff --git a/src/app/main/services/sorting.service.ts b/src/app/main/services/sorting.service.ts new file mode 100644 index 0000000000..a3b1cd8c09 --- /dev/null +++ b/src/app/main/services/sorting.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class SortingService { + + constructor() { } + + /** + * reverses an array + */ + reverseArray(value: Array): Array { + return value.slice().reverse(); + } + + /** + * compares value by value and sorts in alphabetical order using the provided key + * optionally, you can have the array returned to you in reversed order by setting the reversed parameter to 'true' + */ + keySortByAlphabetical(value: Array, sortKey: keyof T, reversed = false): Array { + const sortedArray = value.slice(); + sortedArray.sort((a: T, b: T) => { + if (String(a[sortKey]).toLowerCase() < String(b[sortKey]).toLowerCase()) { + return -1; + } else if (String(a[sortKey]).toLowerCase() > String(b[sortKey]).toLowerCase()) { + return 1; + } else { + return 0; + } + }); + if (reversed) { + sortedArray.reverse(); + } + return sortedArray; + } +} diff --git a/src/assets/http/statusMsg.ts b/src/assets/http/statusMsg.ts new file mode 100644 index 0000000000..a13f13eaac --- /dev/null +++ b/src/assets/http/statusMsg.ts @@ -0,0 +1,263 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class StatusMsg { + + default: any = { + 100: { + message: 'Continue', + description: 'The server has received the request headers, and the client should proceed to send the request body' + }, + 101: { + message: 'Switching Protocols', + description: 'The requester has asked the server to switch protocols' + }, + 103: { + message: 'Checkpoint', + description: 'Used in the resumable requests proposal to resume aborted PUT or POST requests' + }, + 200: { + message: 'OK', + description: 'The request is OK (this is the standard response for successful HTTP requests)' + }, + 201: { + message: 'Created', + description: 'The request has been fulfilled, and a new resource is created' + }, + 202: { + message: 'Accepted', + description: 'The request has been accepted for processing, but the processing has not been completed' + }, + 203: { + message: 'Non-Authoritative Information', + description: 'The request has been successfully processed, but is returning information that may be from another source' + }, + 204: { + message: 'No Content', + description: 'The request has been successfully processed, but is not returning any content' + }, + 205: { + message: 'Reset Content', + description: 'The request has been successfully processed, but is not returning any content, and requires that the requester reset the document view' + }, + 206: { + message: 'Partial Content', + description: 'The server is delivering only part of the resource due to a range header sent by the client' + }, + 300: { + message: 'Multiple Choices', + description: 'A link list. The user can select a link and go to that location. Maximum five addresses' + }, + 301: { + message: 'Moved Permanently', + description: 'The requested page has moved to a new URL' + }, + 302: { + message: 'Found', + description: 'The requested page has moved temporarily to a new URL' + }, + 303: { + message: 'See Other', + description: 'The requested page can be found under a different URL' + }, + 304: { + message: 'Not Modified', + description: 'Indicates the requested page has not been modified since last requested' + }, + 306: { + message: 'Switch Proxy', + description: '-- No longer used --' + }, + 307: { + message: 'Temporary Redirect', + description: 'The requested page has moved temporarily to a new URL' + }, + 308: { + message: 'Resume Incomplete', + description: 'Used in the resumable requests proposal to resume aborted PUT or POST requests' + }, + 400: { + message: 'Bad Request', + description: 'The request cannot be fulfilled due to bad syntax' + }, + 401: { + message: 'Unauthorized', + description: 'The request was a legal request, but the server is refusing to respond to it. For use when authentication is possible but has failed or not yet been provided' + }, + 402: { + message: 'Payment Required', + description: '-- Reserved for future use --' + }, + 403: { + message: 'Forbidden', + description: 'The request was a legal request, but the server is refusing to respond to it' + }, + 404: { + message: 'Not Found', + description: 'The requested page could not be found but may be available again in the future' + }, + 405: { + message: 'Method Not Allowed', + description: 'A request was made of a page using a request method not supported by that page' + }, + 406: { + message: 'Not Acceptable', + description: 'The server can only generate a response that is not accepted by the client' + }, + 407: { + message: 'Proxy Authentication Required', + description: 'The client must first authenticate itself with the proxy' + }, + 408: { + message: 'Request Timeout', + description: 'The server timed out waiting for the request' + }, + 409: { + message: 'Conflict', + description: 'The request could not be completed because of a conflict in the request' + }, + 410: { + message: 'Gone', + description: 'The requested page is no longer available' + }, + 411: { + message: 'Length Required', + description: 'The "Content-Length" is not defined. The server will not accept the request without it' + }, + 412: { + message: 'Precondition Failed', + description: 'The precondition given in the request evaluated to false by the server' + }, + 413: { + message: 'Request Entity Too Large', + description: 'The server will not accept the request, because the request entity is too large' + }, + 414: { + message: 'Request-URI Too Long', + description: 'The server will not accept the request, because the URL is too long. Occurs when you convert a POST request to a GET request with a long query information' + }, + 415: { + message: 'Unsupported Media Type', + description: 'The server will not accept the request, because the media type is not supported' + }, + 416: { + message: 'Requested Range Not Satisfiable', + description: 'The client has asked for a portion of the file, but the server cannot supply that portion' + }, + 417: { + message: 'Expectation Failed', + description: 'The server cannot meet the requirements of the Expect request-header field' + }, + 418: { + message: 'I\'m a teapot', + description: 'Any attempt to brew coffee with a teapot should result in the error code "418 I\'m a teapot". The resulting entity body MAY be short and stout' + }, + 421: { + message: 'Misdirected Request', + description: 'The request was directed at a server that is not able to produce a response (for example because a connection reuse)' + }, + 422: { + message: 'Unprocessable Entity', + description: 'The request was well-formed but was unable to be followed due to semantic errors' + }, + 423: { + message: 'Locked', + description: 'The resource that is being accessed is locked' + }, + 424: { + message: 'Failed Dependency', + description: 'The request failed due to failure of a previous request (e.g., a PROPPATCH)' + }, + 426: { + message: 'Upgrade Required', + description: 'The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field' + }, + 428: { + message: 'Precondition Required', + description: 'The origin server requires the request to be conditional' + }, + 429: { + message: 'Too Many Requests', + description: 'The user has sent too many requests in a given amount of time. Intended for use with rate limiting schemes' + }, + 431: { + message: 'Request Header Fields Too Large', + description: 'The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large' + }, + 451: { + message: 'Unavailable For Legal Reasons', + description: 'A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource' + }, + 500: { + message: 'Internal Server Error', + description: 'An error has occured in a server side script, a no more specific message is suitable' + }, + 501: { + message: 'Not Implemented', + description: 'The server either does not recognize the request method, or it lacks the ability to fulfill the request' + }, + 502: { + message: 'Bad Gateway', + description: 'The server was acting as a gateway or proxy and received an invalid response from the upstream server' + }, + 503: { + message: 'Service Unavailable', + description: 'The server is currently unavailable (overloaded or down)' + }, + 504: { + message: 'Gateway Timeout', + description: 'The server was acting as a gateway or proxy and did not receive a timely response from the upstream server' + }, + 505: { + message: 'HTTP Version Not Supported', + description: 'The server does not support the HTTP protocol version used in the request' + }, + 511: { + message: 'Network Authentication Required', + description: 'The client needs to authenticate to gain network access' + }, + info: { + lastUpdate: { + date: '20160411', + log: '4xx Codes updated via Wikipedia' + }, + references: { + W3Schools: 'http://www.w3schools.com/tags/ref_httpmessages.asp', + Wikipedia: 'https://en.wikipedia.org/wiki/List_of_HTTP_status_codes' + }, + codeLookup: { + info: 'Use the \'code\' variable in the url to lookup and individual code', + demoURL: '/statusMsg/?code=500' + }, + htmlDisplay: { + info: 'Add the \'html\' variable to your code lookup to get the error displayed in a nice html template', + demoURL: '/statusMsg/?code=404&html' + }, + invalidCode: 'If an invalid code is given, the site will just show the json list of all codes', + credits: 'This site was crafted by Unreal Designs and is powered by UDCDN' + }, + '1xx': { + message: 'Information', + description: '' + }, + '2xx': { + message: 'Successful', + description: '' + }, + '3xx': { + message: 'Redirection', + description: '' + }, + '4xx': { + message: 'Client Error', + description: '' + }, + '5xx': { + message: 'Server Error', + description: '' + } + }; + +} diff --git a/src/assets/style/_viewer.scss b/src/assets/style/_viewer.scss new file mode 100644 index 0000000000..487cdee173 --- /dev/null +++ b/src/assets/style/_viewer.scss @@ -0,0 +1,213 @@ +@import "./config"; +@import "./mixins"; +@import "./responsive"; + +// css for parent value components + +.read-mode-view { + font: 400 15px/24px sans-serif; + .rm-value, .rm-comment { + display: block; + margin: 0px; + } +} + +.child-value-component, +.value-component-comment { + display: block; +} + +.parent-component-wrapper .value-component-comment { + margin-top: -2em; +} + +.parent-value-component .child-value-component .mat-form-field-wrapper .mat-form-field-infix { + border-top: 0em solid transparent; +} + + +// css for child components of a parent value component + +.child-input-component { + display: inline-block; + vertical-align: bottom; + width: 49%; + &:nth-child(2) { + padding-left: 2%; + } +} + +.child-input-component.full-width { + width: 100%; +} + +// custom error message + +.custom-error-message { + font-size: 12px; + margin-top: -1em; + padding-bottom: 1em; +} + +// viewer operations + +.grid-container { + display: grid; + grid-template-columns: 70% 30%; + grid-template-rows: auto auto; +} + +.value-component, +.crud-buttons { + vertical-align: top; +} + +.value-component { + grid-row-start: 1; + grid-row-end: auto; + grid-column-start: 1; + grid-column-end: 2; + position: relative; + + .action-bubble { + position: absolute; + right: 0; + top: -20px; + border: 1px solid #e4e4e4; + border-radius: 10px; + padding: 0 1px; + background-color: #e4e4e4; + z-index: 2; + box-shadow: #949494 1px 4px 5px 0px; + + .button-container { + + button.mat-button-disabled { + + .mat-icon { + color: #aaaaaa; + } + + } + + button { + cursor: pointer; + border: none; + padding: 2px; + outline: none; + background-color: transparent; + color: #000000; + margin: 0 2px; + border-radius: 10px; + transition: background-color ease-out 0.5s; + min-width: inherit; + line-height: normal; + + .material-icons { + font-size: 18px; + } + + .mat-icon { + width: 18px; + height: 18px; + vertical-align: middle; + } + } + + button.info { + cursor: default; + } + + button:hover { + background-color: #c7c7c7; + } + } + } +} + +.value-component.highlighted { + background-color: #f5f5f5; +} + +.crud-buttons { + grid-row-start: 1; + grid-row-end: auto; + grid-column-start: 2; + grid-column-end: 3; +} + +.mat-error { + grid-row-start: 1; + grid-row-end: auto; + grid-column-start: 3; + grid-column-end: end; +} + +button.save, +button.cancel { + cursor: pointer; + border: none; + padding: 0em; + margin-left: 1em; + outline: none; + background-color: transparent; + color: #000000; +} + +button.save:disabled { + color: #adadad; + cursor: default; +} + +button.save .material-icons, +button.cancel .material-icons { + font-size: 18px; +} + +button.save .mat-icon, +button.cancel .mat-icon { + font-size: 18px; +} + +.deletion-dialog .title { + font-weight: bold; + font-size: 18px; + text-align: center; +} + +.deletion-dialog .action-buttons { + float: right; + + .cancel { + padding: 0 16px; + } +} + +.deletion-dialog-message { + font-size: 16px; + background-color: #ededf5; + border: 1px solid #d8d8df; + border-radius: 5px; + padding: 20px 10px 20px 10px; + text-align: center; +} + +.deletion-dialog-message .deletion-comment { + min-width: 80%; +} + + +.ck-content code { + font-family: monospace !important; +} + +.ck-content pre { + background-color: inherit !important; +} + +code, pre { + font-family: monospace !important; + background-color: hsla(0,0%,78%,.3); + padding: .15em; + border-radius: 2px; +}