diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d697dd0c37..fcbdd8dfa9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,8 @@ on: branches: - dtq-dev - customer/* + schedule: + - cron: '0 */4 * * *' pull_request: workflow_dispatch: diff --git a/.github/workflows/import-weekly.yml b/.github/workflows/import-weekly.yml new file mode 100644 index 00000000000..6ca37989703 --- /dev/null +++ b/.github/workflows/import-weekly.yml @@ -0,0 +1,13 @@ +name: Import Weekly + +on: + schedule: + - cron: '0 0 * * 0' # every Sunday at midnight UTC + +jobs: + deploy: + uses: ./.github/workflows/deploy.yml + with: + INSTANCE: '8' + IMPORT: true + ERASE_DB: true diff --git a/Acknowledgment-ReadMe.md b/Acknowledgment-ReadMe.md new file mode 100644 index 00000000000..b2caaf86282 --- /dev/null +++ b/Acknowledgment-ReadMe.md @@ -0,0 +1,13 @@ +

+ EOSC CZ Logo +

+ +--- + +This project output was developed with financial contributions from the [EOSC CZ](https://www.eosc.cz/projekty/narodni-podpora-pro-eosc) initiative through the project **National Repository Platform for Research Data** (CZ.02.01.01/00/23_014/0008787), funded by the Programme Johannes Amos Comenius (P JAC) of the Ministry of Education, Youth and Sports of the Czech Republic (MEYS). + +--- + +

+ EU and MŠMT Logos +

diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index 5dd5f995ed5..00cd919873a 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -133,9 +133,6 @@ services: restart: unless-stopped container_name: dspacesolr${INSTANCE} image: ${DSPACE_SOLR_IMAGE:-dataquest/dspace-solr:dspace-7_x} - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace networks: dspacenet: ports: @@ -146,9 +143,6 @@ services: tty: true working_dir: /var/solr/data volumes: - # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) - # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume - - solr_configs:/opt/solr/server/solr/configsets/dspace # Keep Solr data directory between reboots - solr_data:/var/solr/data - solr_logs:/var/solr/logs @@ -162,14 +156,14 @@ services: - '-c' - | init-var-solr - precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai - precreate-core search /opt/solr/server/solr/configsets/dspace/search - cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics - cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics exec solr -p 898${INSTANCE} -f -m 4g volumes: # Commented out because there are a lot of files in the assetstore @@ -177,7 +171,6 @@ volumes: pgdata: solr_data: # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: dspace_logs: solr_logs: handle_server: diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9c88a883289..0e8e8dcfaa6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,8 @@ import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { StoreDevModules } from '../config/store/devtools'; import { RootModule } from './root.module'; import { ScriptLoaderService } from './clarin-navbar-top/script-loader-service'; +import { UrlSerializer } from '@angular/router'; +import { BitstreamUrlSerializer } from './core/url-serializer/bitstream-url-serializer'; export function getConfig() { return environment; @@ -105,6 +107,7 @@ const PROVIDERS = [ useClass: LogInterceptor, multi: true }, + { provide: UrlSerializer, useClass: BitstreamUrlSerializer }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/bitstream-page/clarin-license-agreement-page/clarin-license-agreement-page.component.ts b/src/app/bitstream-page/clarin-license-agreement-page/clarin-license-agreement-page.component.ts index fe5e6368593..0a16184d0e6 100644 --- a/src/app/bitstream-page/clarin-license-agreement-page/clarin-license-agreement-page.component.ts +++ b/src/app/bitstream-page/clarin-license-agreement-page/clarin-license-agreement-page.component.ts @@ -169,10 +169,13 @@ export class ClarinLicenseAgreementPageComponent implements OnInit { * Load the content for the special license. This content is shown directly in this approval page. */ loadLicenseContentSeznam() { - this.htmlContentService.getHmtlContentByPathAndLocale(this.LICENSE_PATH_SEZNAM_CZ).then(content => { - this.licenseContentSeznam.next(content); + this.item$.subscribe((item) => { + if (item.firstMetadataValue('dc.rights') === this.LICENSE_NAME_SEZNAM) { + this.htmlContentService.getHmtlContentByPathAndLocale(this.LICENSE_PATH_SEZNAM_CZ).then(content => { + this.licenseContentSeznam.next(content); + }); + } }); - return true; } public accept() { diff --git a/src/app/bitstream-page/legacy-bitstream-url.resolver.ts b/src/app/bitstream-page/legacy-bitstream-url.resolver.ts index 948bec24731..54c44d41152 100644 --- a/src/app/bitstream-page/legacy-bitstream-url.resolver.ts +++ b/src/app/bitstream-page/legacy-bitstream-url.resolver.ts @@ -6,6 +6,7 @@ import { Bitstream } from '../core/shared/bitstream.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { hasNoValue } from '../shared/empty.util'; import { BitstreamDataService } from '../core/data/bitstream-data.service'; +import { encodeRFC3986URIComponent } from '../shared/clarin-shared-util'; /** * This class resolves a bitstream based on the DSpace 6 XMLUI or JSPUI bitstream download URLs @@ -40,7 +41,7 @@ export class LegacyBitstreamUrlResolver implements Resolve return this.bitstreamDataService.findByItemHandle( `${prefix}/${suffix}`, sequenceId, - filename, + encodeRFC3986URIComponent(filename), ).pipe( getFirstCompletedRemoteData() ); diff --git a/src/app/core/metadata/metadata-bitstream.model.ts b/src/app/core/metadata/metadata-bitstream.model.ts index c5eed0594a4..eace9ba1121 100644 --- a/src/app/core/metadata/metadata-bitstream.model.ts +++ b/src/app/core/metadata/metadata-bitstream.model.ts @@ -31,7 +31,7 @@ export class MetadataBitstream extends ListableObject implements HALResource { * The identifier of this metadata field */ @autoserialize - id: number; + id: string; /** * The name of this bitstream diff --git a/src/app/core/url-serializer/bitstream-url-serializer.spec.ts b/src/app/core/url-serializer/bitstream-url-serializer.spec.ts new file mode 100644 index 00000000000..b19717d3d39 --- /dev/null +++ b/src/app/core/url-serializer/bitstream-url-serializer.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { BitstreamUrlSerializer } from './bitstream-url-serializer'; +import { DefaultUrlSerializer, UrlTree } from '@angular/router'; + +describe('BitstreamUrlSerializer', () => { + let serializer: BitstreamUrlSerializer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BitstreamUrlSerializer] + }); + serializer = TestBed.inject(BitstreamUrlSerializer); + }); + + it('should be created', () => { + expect(serializer).toBeTruthy(); + }); + + it('should not modify URLs that do not start with /bitstream/', () => { + const url = '/some/other/path/file.pdf'; + const result = serializer.parse(url); + const expected = new DefaultUrlSerializer().parse(url); + expect(result).toEqual(expected); + }); + + it('should encode special characters in the filename in /bitstream/ URLs', () => { + const originalUrl = '/bitstream/id/123/456/some file(name)[v1].pdf'; + const expectedEncodedFilename = 'some%20file%28name%29%5Bv1%5D.pdf'; + const expectedUrl = `/bitstream/id/123/456/${expectedEncodedFilename}`; + + const result: UrlTree = serializer.parse(originalUrl); + + const resultUrl = new DefaultUrlSerializer().serialize(result); + expect(resultUrl).toBe(expectedUrl); + }); + + it('should not modify /bitstream/ URL if there is no filename', () => { + const url = '/bitstream/id/123/456'; + const result = serializer.parse(url); + const expected = new DefaultUrlSerializer().parse(url); + expect(result).toEqual(expected); + }); +}); diff --git a/src/app/core/url-serializer/bitstream-url-serializer.ts b/src/app/core/url-serializer/bitstream-url-serializer.ts new file mode 100644 index 00000000000..01c6bfa1e30 --- /dev/null +++ b/src/app/core/url-serializer/bitstream-url-serializer.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { DefaultUrlSerializer, UrlTree } from '@angular/router'; +import { encodeRFC3986URIComponent } from '../../shared/clarin-shared-util'; + +/** + * This class intercepts the parsing of URLs to ensure that the filename in the URL is properly encoded. + * But it only does this for URLs that start with '/bitstream/'. + */ +@Injectable({ providedIn: 'root' }) +export class BitstreamUrlSerializer extends DefaultUrlSerializer { + FILENAME_INDEX = 5; + // Intercept parsing of every URL + parse(url: string): UrlTree { + if (url.startsWith('/bitstream/')) { + // Split the URL to isolate the filename + const parts = url.split('/'); + if (parts.length > this.FILENAME_INDEX) { + // Fetch the filename from the URL + const filename = parts.slice(this.FILENAME_INDEX).join(); + const encodedFilename = encodeRFC3986URIComponent(filename); + // Reconstruct the URL with the encoded filename + url = [...parts.slice(0, this.FILENAME_INDEX), encodedFilename].join('/'); + } + } + return super.parse(url); + } +} diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts b/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts index f5e795d4d0e..a1455425ebf 100644 --- a/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts +++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts @@ -23,7 +23,7 @@ describe('ClarinFilesSectionComponent', () => { let halService: HALEndpointService; // Set up the mock service's getMetadataBitstream method to return a simple stream const metadatabitstream = new MetadataBitstream(); - metadatabitstream.id = 123; + metadatabitstream.id = '70ccc608-f6a5-4c96-ab2d-53bc56ae8ebe'; metadatabitstream.name = 'test'; metadatabitstream.description = 'test'; metadatabitstream.fileSize = 1024; diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.ts b/src/app/item-page/clarin-files-section/clarin-files-section.component.ts index f9092be3020..cd7575d4d62 100644 --- a/src/app/item-page/clarin-files-section/clarin-files-section.component.ts +++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.ts @@ -80,7 +80,7 @@ export class ClarinFilesSectionComponent implements OnInit { ngOnInit(): void { this.registryService - .getMetadataBitstream(this.itemHandle, 'ORIGINAL,TEXT,THUMBNAIL') + .getMetadataBitstream(this.itemHandle, 'ORIGINAL') .pipe(getAllSucceededRemoteListPayload()) .subscribe((data: MetadataBitstream[]) => { this.listOfFiles.next(data); diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html index 3d029dfef6c..91287138d49 100644 --- a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html @@ -1,13 +1,15 @@
-
{{'item.file.description.name' | translate}}
-
+
{{ fileInput.name }}
{{'item.file.description.size' | translate}}
@@ -29,7 +31,8 @@
Preview { let component: FileDescriptionComponent; let fixture: ComponentFixture; let halService: HALEndpointService; + let bitstreamDataService: BitstreamDataService; beforeEach(async () => { const configurationDataService = jasmine.createSpyObj('configurationDataService', { @@ -32,6 +41,10 @@ describe('FileDescriptionComponent', () => { getRootHref: 'root url', }); + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findById: createSuccessfulRemoteDataObject$(new Bitstream()), + }); + await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -42,7 +55,11 @@ describe('FileDescriptionComponent', () => { declarations: [FileDescriptionComponent, FileSizePipe], providers: [ { provide: ConfigurationDataService, useValue: configurationDataService }, - { provide: HALEndpointService, useValue: halService } + { provide: HALEndpointService, useValue: halService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: BitstreamDataService, useValue: bitstreamDataService }, ] }).compileComponents(); }); @@ -53,7 +70,7 @@ describe('FileDescriptionComponent', () => { // Mock the input value const fileInput = new MetadataBitstream(); - fileInput.id = 123; + fileInput.id = '66efe81e-2950-483d-a065-bbdacd689f95'; fileInput.name = 'testFile'; fileInput.description = 'test description'; fileInput.fileSize = 2048; diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts index c02d94f956b..f59479a9f3c 100644 --- a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts @@ -1,9 +1,20 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; import { Router } from '@angular/router'; import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; -import { getFirstSucceededRemoteData } from '../../../../../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../../../../core/shared/operators'; +import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { fromEvent, merge, Observable, of, Subscription } from 'rxjs'; +import { FileService } from '../../../../../core/shared/file.service'; +import { distinctUntilChanged, switchMap, take } from 'rxjs/operators'; +import { FeatureID } from '../../../../../core/data/feature-authorization/feature-id'; +import { hasValue } from '../../../../../shared/empty.util'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; const allowedPreviewFormats = ['text/plain', 'text/html', 'application/zip', 'application/x-tar']; @Component({ @@ -11,17 +22,30 @@ const allowedPreviewFormats = ['text/plain', 'text/html', 'application/zip', 'ap templateUrl: './file-description.component.html', styleUrls: ['./file-description.component.scss'], }) -export class FileDescriptionComponent implements OnInit { +export class FileDescriptionComponent implements OnInit, OnDestroy { MIME_TYPE_IMAGES_PATH = './assets/images/mime/'; MIME_TYPE_DEFAULT_IMAGE_NAME = 'application-octet-stream.png'; @Input() fileInput: MetadataBitstream; + @ViewChild('videoPreview') videoElement: ElementRef; + emailToContact: string; + content_url$: Observable; + content_url: string; + thumbnail_url$: Observable; + handlers_added = false; + playPromise: Promise; + + private subscriptions: Subscription = new Subscription(); constructor(protected halService: HALEndpointService, private router: Router, + private bitstreamService: BitstreamDataService, + private auth: AuthService, + private authDataService: AuthorizationDataService, + private fileService: FileService, private configService: ConfigurationDataService) { } ngOnInit(): void { @@ -30,6 +54,122 @@ export class FileDescriptionComponent implements OnInit { .subscribe(remoteData => { this.emailToContact = remoteData?.payload?.values?.[0]; }); + this.content_url$ = this.bitstreamService.findById(this.fileInput.id, true, false, followLink('thumbnail')) + .pipe(getFirstCompletedRemoteData(), + switchMap((remoteData: RemoteData) => { + if (remoteData.hasSucceeded) { + if (remoteData.payload?.thumbnail){ + this.thumbnail_url$ = remoteData.payload?.thumbnail.pipe( + switchMap((thumbnailRD: RemoteData) => { + if (thumbnailRD.hasSucceeded) { + return this.buildUrl(thumbnailRD.payload?._links.content.href); + } else { + return of(''); + } + }), + ); + } else { + this.thumbnail_url$ = of(''); + } + return of(remoteData.payload?._links.content.href); + } + } + )); + this.content_url$.pipe(take(1)).subscribe((url) => { + this.content_url = url; + }); + } + + ngAfterViewInit() { + const video = this.videoElement?.nativeElement; + + if (video) { + const error$ = fromEvent(video, 'error'); + this.subscriptions.add( + error$.subscribe((event) => { + //console.log('error', video.error.message); + if (hasValue(video.src)) { + this.auth.isAuthenticated().pipe( + switchMap((isLoggedIn) => { + if (isLoggedIn) { + return this.authDataService.isAuthorized(FeatureID.CanDownload, this.content_url.replace('/content', '')); + } else { + return of(false); + } + }), + ).subscribe((isAuthorized: boolean) => { + if (isAuthorized) { + this.add_short_lived_token_handling_to_video_playback(video); + this.resetSource(); + } else { + video.src = null; + } + }); + } + }) + ); + } + } + + private add_short_lived_token_handling_to_video_playback(video: HTMLVideoElement) { + if (this.handlers_added) { + return; + } + + const seeking$ = fromEvent(video, 'seeking').pipe( + switchMap((event: Event) => { + //console.log('seeking'); + return of(video.currentTime); + }), + distinctUntilChanged(), + ); + const stalled$ = fromEvent(video, 'stalled').pipe( + switchMap((event: Event) => { + //console.log('stalled'); + return of(video.currentTime); + }), + distinctUntilChanged(), + ); + this.subscriptions.add( + merge(seeking$, stalled$).subscribe((currentTime) => { + this.resetSource(currentTime); + }) + ); + + this.handlers_added = true; + + } + + private resetSource(currentTime?) { + const video = this.videoElement?.nativeElement; + //console.log("networkState in resetSource", video.networkState); + if (this.playPromise) { + this.playPromise?.then(_ => { + //playback has started + // don't want to see The play() request was interrupted by... + // https://developer.chrome.com/blog/play-request-was-interrupted + this.updateSource(video, currentTime); + }).catch(_ => { + //do nothing + }); + } else { + this.updateSource(video, currentTime); + } + } + + private updateSource(video, currentTime) { + //console.log("Updating the src"); + this.buildUrl(this.content_url).subscribe(result => { + video.src = result; + if (currentTime) { + video.currentTime = currentTime; + } + this.playPromise = video.play(); + }); + } + + private buildUrl(url: string): Observable { + return url ? this.fileService.retrieveFileDownloadLink(url) : of(url); } public downloadFile() { @@ -68,4 +208,8 @@ export class FileDescriptionComponent implements OnInit { // this.fileInput.fileInfo.length === 0 means that the file has no preview return this.fileInput?.fileInfo?.length === 0; } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } } diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts index 827b53a4cd6..1bac02083e7 100644 --- a/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts @@ -43,7 +43,7 @@ describe('PreviewSectionComponent', () => { // Set up the mock service's getMetadataBitstream method to return a simple stream const metadatabitstream = new MetadataBitstream(); - metadatabitstream.id = 123; + metadatabitstream.id = '5974f1cf-f2ef-4e4c-8f6d-85ad6c52efde'; metadatabitstream.name = 'test'; metadatabitstream.description = 'test'; metadatabitstream.fileSize = 1024; diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts index 3f485e70f08..d8dd5faad53 100644 --- a/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts @@ -22,7 +22,7 @@ export class PreviewSectionComponent implements OnInit { ngOnInit(): void { this.registryService - .getMetadataBitstream(this.item.handle, 'ORIGINAL,TEXT,THUMBNAIL') + .getMetadataBitstream(this.item.handle, 'ORIGINAL') .pipe(getAllSucceededRemoteListPayload()) .subscribe((data: MetadataBitstream[]) => { this.listOfFiles.next(data); diff --git a/src/app/login-page/login-page.component.html b/src/app/login-page/login-page.component.html index 3b9cd4484eb..565ab883c01 100644 --- a/src/app/login-page/login-page.component.html +++ b/src/app/login-page/login-page.component.html @@ -1,10 +1,12 @@
- - - - +
+ + + + +

{{"login.form.header" | translate}}

diff --git a/src/app/shared/clarin-shared-util.ts b/src/app/shared/clarin-shared-util.ts index f82987f9e0f..1603ed85c14 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -87,3 +87,16 @@ export function makeLinks(text: string): string { const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g; return text?.replace(regex, (url) => `${url}`); } + +/** + * Encode special characters in a URI part to ensure it is safe for use in a URL. + * The special characters are `()[]` and the space character. + * @param uriPart + */ +export function encodeRFC3986URIComponent(uriPart: string) { + // Decode the filename to handle any encoded characters + const decodedFileName = decodeURIComponent(uriPart); + // Encode special characters in the filename + return encodeURIComponent(decodedFileName) + .replace(/[()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 33caa182f18..0bbf45d36d0 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -16,14 +16,14 @@ (prev)="goPrev()" (next)="goNext()">
    -
    +
    -
    +