diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 175a3fd1609..306b950f8f4 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -64,6 +64,7 @@ import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { MetadataField } from './metadata/metadata-field.model'; +import { MetadataBitstream } from './metadata/metadata-bitstream.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; import { MetadataService } from './metadata/metadata.service'; import { RegistryService } from './registry/registry.service'; @@ -133,6 +134,7 @@ import { import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from './data/metadata-bitstream-data.service'; import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; @@ -298,6 +300,7 @@ const PROVIDERS = [ SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, + MetadataBitstreamDataService, TokenResponseParsingService, ReloadGuard, EndUserAgreementCurrentUserGuard, @@ -341,6 +344,7 @@ export const models = ResourcePolicy, MetadataSchema, MetadataField, + MetadataBitstream, License, WorkflowItem, WorkspaceItem, diff --git a/src/app/core/data/metadata-bitstream-data.service.ts b/src/app/core/data/metadata-bitstream-data.service.ts new file mode 100644 index 00000000000..31249318334 --- /dev/null +++ b/src/app/core/data/metadata-bitstream-data.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { METADATA_BITSTREAM } from '../metadata/metadata-bitstream.resource-type'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { ChangeAnalyzer } from './change-analyzer'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { CoreState } from '../core-state.model'; +import { dataService } from './base/data-service.decorator'; +import { FindListOptions } from './find-list-options.model'; +import { linkName } from './clarin/clarin-license-data.service'; +import { PaginatedList } from './paginated-list.model'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint + */ +@Injectable() +@dataService(METADATA_BITSTREAM) +export class MetadataBitstreamDataService extends IdentifiableDataService implements SearchData { + protected store: Store; + protected http: HttpClient; + protected comparator: ChangeAnalyzer; + protected linkPath = 'metadatabitstreams'; + protected searchByHandleLinkPath = 'byHandle'; + private searchData: SearchData; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super(linkName, requestService, rdbService, objectCache, halService, undefined); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to + * at least the schema, element or qualifier + * @param handle optional; an exact match of the prefix of the item identifier (e.g. "123456789/1126") + * @param fileGrpType optional; an exact match of the type of the file(e.g. "TEXT", "THUMBNAIL") + * @param options The options info used to retrieve the fields + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByHandleParams(handle: string, fileGrpType: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const optionParams = Object.assign(new FindListOptions(), options, { + searchParams: [ + new RequestParam('handle', hasValue(handle) ? handle : ''), + new RequestParam( + 'fileGrpType', + hasValue(fileGrpType) ? fileGrpType : '' + ), + ], + }); + return this.searchBy(this.searchByHandleLinkPath, optionParams, useCachedVersionIfAvailable, reRequestOnStale, + ...linksToFollow); + } + + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/metadata/file-info.model.ts b/src/app/core/metadata/file-info.model.ts new file mode 100644 index 00000000000..c2bc189189a --- /dev/null +++ b/src/app/core/metadata/file-info.model.ts @@ -0,0 +1,12 @@ +import { autoserialize, autoserializeAs } from 'cerialize'; + +/** + * This class is used to store the information about a file or a directory + */ +export class FileInfo { + @autoserialize name: string; + @autoserialize content: any; + @autoserialize size: string; + @autoserialize isDirectory: boolean; + @autoserializeAs('sub') sub: { [key: string]: FileInfo }; +} diff --git a/src/app/core/metadata/metadata-bitstream.model.ts b/src/app/core/metadata/metadata-bitstream.model.ts new file mode 100644 index 00000000000..369d3552b4a --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.model.ts @@ -0,0 +1,97 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { typedObject } from '../cache/builders/build-decorators'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { ResourceType } from '../shared/resource-type'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { METADATA_BITSTREAM } from './metadata-bitstream.resource-type'; +import { FileInfo } from './file-info.model'; + +/** + * Class that represents a MetadataBitstream + */ +@typedObject +export class MetadataBitstream extends ListableObject implements HALResource { + static type = METADATA_BITSTREAM; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this metadata field + */ + @autoserialize + id: number; + + /** + * The name of this bitstream + */ + @autoserialize + name: string; + + /** + * The description of this bitstream + */ + @autoserialize + description: string; + + /** + * The fileSize of this bitstream + */ + @autoserialize + fileSize: string; + + /** + * The checksum of this bitstream + */ + @autoserialize + checksum: string; + + /** + * The fileInfo of this bitstream + */ + @autoserializeAs(FileInfo, 'fileInfo') fileInfo: FileInfo[]; + + /** + * The format of this bitstream + */ + @autoserialize + format: string; + + /** + * The href of this bitstream + */ + @autoserialize + href: string; + + /** + * The canPreview of this bitstream + */ + @autoserialize + canPreview: boolean; + + /** + * The {@link HALLink}s for this MetadataField + */ + @deserialize + _links: { + self: HALLink; + schema: HALLink; + }; + + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } +} +export { FileInfo }; + diff --git a/src/app/core/metadata/metadata-bitstream.resource-type.ts b/src/app/core/metadata/metadata-bitstream.resource-type.ts new file mode 100644 index 00000000000..6214c846169 --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../shared/resource-type'; + +/** + * The resource type for MetadataBitstream + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const METADATA_BITSTREAM = new ResourceType('metadatabitstream'); diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index e9dfbe7e2c3..271f94ad00c 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable, of as observableOf, of } from 'rxjs'; import { MetadataRegistryCancelFieldAction, MetadataRegistryCancelSchemaAction, @@ -30,6 +30,7 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { RemoteData } from '../data/remote-data'; import { NoContent } from '../shared/NoContent.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { MetadataBitstreamDataService } from '../data/metadata-bitstream-data.service'; @Component({ template: '' }) class DummyComponent { @@ -40,6 +41,7 @@ describe('RegistryService', () => { let mockStore; let metadataSchemaService: MetadataSchemaDataService; let metadataFieldService: MetadataFieldDataService; + let metadataBitstreamDataService: MetadataBitstreamDataService; let options: FindListOptions; let mockSchemasList: MetadataSchema[]; @@ -139,6 +141,14 @@ describe('RegistryService', () => { delete: createNoContentRemoteDataObject$(), clearRequests: observableOf('href') }); + metadataBitstreamDataService = jasmine.createSpyObj( + 'metadataBitstreamDataService', + { + searchByHandleParams: of({ + /* Your Mock Data */ + }), + } + ); } beforeEach(() => { @@ -153,6 +163,8 @@ describe('RegistryService', () => { { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: MetadataSchemaDataService, useValue: metadataSchemaService }, { provide: MetadataFieldDataService, useValue: metadataFieldService }, + { provide: MetadataFieldDataService, useValue: metadataFieldService }, + { provide: MetadataBitstreamDataService, useValue: metadataBitstreamDataService }, RegistryService ] }); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 7a377405bd0..ede05f718a2 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -26,10 +26,12 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'; import { MetadataFieldDataService } from '../data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from '../data/metadata-bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RequestParam } from '../cache/models/request-param.model'; import { NoContent } from '../shared/NoContent.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); @@ -42,12 +44,12 @@ const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelec */ @Injectable() export class RegistryService { - constructor(private store: Store, private notificationsService: NotificationsService, private translateService: TranslateService, private metadataSchemaService: MetadataSchemaDataService, - private metadataFieldService: MetadataFieldDataService) { + private metadataFieldService: MetadataFieldDataService, + private metadataBitstreamDataService: MetadataBitstreamDataService) { } @@ -104,6 +106,22 @@ export class RegistryService { return this.metadataFieldService.findBySchema(schema, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * retrieves all metadatabistream that belong to a certain metadata + * @param schema The schema to filter by + * @param options The options info used to retrieve the fields + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public getMetadataBitstream(handle: string, fileGrpType: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.metadataBitstreamDataService.searchByHandleParams(handle, fileGrpType, options, + useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + public editMetadataSchema(schema: MetadataSchema) { this.store.dispatch(new MetadataRegistryEditSchemaAction(schema)); } diff --git a/src/app/core/shared/clarin/constants.ts b/src/app/core/shared/clarin/constants.ts index 5dc8e9be50f..45093a4a88d 100644 --- a/src/app/core/shared/clarin/constants.ts +++ b/src/app/core/shared/clarin/constants.ts @@ -6,4 +6,3 @@ export const DOWNLOAD_TOKEN_EXPIRED_EXCEPTION = 'DownloadTokenExpiredException'; export const HTTP_STATUS_UNAUTHORIZED = 401; export const USER_WITHOUT_EMAIL_EXCEPTION = 'UserWithoutEmailException'; export const MISSING_HEADERS_FROM_IDP_EXCEPTION = 'MissingHeadersFromIpd'; - diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 66c6488b8e4..3ab1b7b051d 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { ItemDataService } from '../../core/data/item-data.service'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; @@ -11,7 +11,7 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { VarDirective } from '../../shared/utils/var.directive'; import { RouterTestingModule } from '@angular/router/testing'; import { Item } from '../../core/shared/item.model'; -import { BehaviorSubject, of as observableOf } from 'rxjs'; +import { BehaviorSubject, of, of as observableOf } from 'rxjs'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -20,6 +20,16 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; import { RemoteData } from '../../core/data/remote-data'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; +import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { MetadataBitstreamDataService } from 'src/app/core/data/metadata-bitstream-data.service'; +import { getMockTranslateService } from 'src/app/shared/mocks/translate.service.mock'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { cold } from 'jasmine-marbles'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -50,14 +60,13 @@ const metadataServiceStub = { describe('FullItemPageComponent', () => { let comp: FullItemPageComponent; let fixture: ComponentFixture; - + let registryService: RegistryService; + let translateService: TranslateService; let authService: AuthService; let routeStub: ActivatedRouteStub; let routeData; let authorizationDataService: AuthorizationDataService; - - beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), @@ -76,6 +85,26 @@ describe('FullItemPageComponent', () => { isAuthorized: observableOf(false), }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => of({}) // Returns a mock Observable + }; + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + let halService: HALEndpointService; + halService = jasmine.createSpyObj('halService', { + 'getEndpoint': cold('a', { a: 'endpointURL' }) + }); + + + translateService = getMockTranslateService(); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -90,6 +119,13 @@ describe('FullItemPageComponent', () => { { provide: MetadataService, useValue: metadataServiceStub }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: MetadataBitstreamDataService, useValue: mockMetadataBitstreamDataService }, + { provide: Store, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: MetadataSchemaDataService, useValue: {} }, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: HALEndpointService, useValue: halService }, + RegistryService ], schemas: [NO_ERRORS_SCHEMA] @@ -99,6 +135,7 @@ describe('FullItemPageComponent', () => { })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(FullItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); @@ -112,19 +149,23 @@ describe('FullItemPageComponent', () => { }); it('should show simple view button when not originated from workflow item', () => { - expect(comp.fromSubmissionObject).toBe(false); - const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); - expect(simpleViewBtn).toBeTruthy(); + waitForAsync(() => { + expect(comp.fromSubmissionObject).toBe(false); + const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); + expect(simpleViewBtn).toBeTruthy(); + }); }); it('should not show simple view button when originated from workflow', fakeAsync(() => { routeData.wfi = createSuccessfulRemoteDataObject$({ id: 'wfiId'}); comp.ngOnInit(); - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(comp.fromSubmissionObject).toBe(true); - const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); - expect(simpleViewBtn).toBeFalsy(); + waitForAsync(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(comp.fromSubmissionObject).toBe(true); + const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); + expect(simpleViewBtn).toBeFalsy(); + }); }); })); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 118e4360048..2b60fb67003 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -16,6 +16,8 @@ import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; import { Location } from '@angular/common'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; /** @@ -48,8 +50,10 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, items: ItemDataService, authService: AuthService, authorizationService: AuthorizationDataService, - private _location: Location) { - super(route, router, items, authService, authorizationService); + protected registryService: RegistryService, + private _location: Location, + protected halService: HALEndpointService,) { + super(route, router, items, authService, authorizationService, registryService, halService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 6ae23988992..6e87e4594a9 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -70,7 +70,14 @@ import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/ import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { ItemSharedModule } from './item-shared.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; - +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import {PreviewSectionComponent} from './simple/field-components/preview-section/preview-section.component'; +import { + FileDescriptionComponent +} from './simple/field-components/preview-section/file-description/file-description.component'; +import { + FileTreeViewComponent +} from './simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -122,7 +129,10 @@ const DECLARATIONS = [ ClarinStatisticsButtonComponent, ClarinGenericItemFieldComponent, ClarinCollectionsItemFieldComponent, - ClarinFilesItemFieldComponent + ClarinFilesItemFieldComponent, + PreviewSectionComponent, + FileDescriptionComponent, + FileTreeViewComponent, ]; @NgModule({ @@ -141,7 +151,8 @@ const DECLARATIONS = [ ResultsBackButtonModule, UploadModule, DsoPageModule, - ChartsModule + ChartsModule, + NgbModule ], declarations: [ ...DECLARATIONS, 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 new file mode 100644 index 00000000000..640c1487eb1 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html @@ -0,0 +1,79 @@ +
+
+ +
+
+
+
{{'item.file.description.name' | translate}}
+
+ {{ fileInput.name }} +
+
{{'item.file.description.size' | translate}}
+
+ {{ fileInput.fileSize }} +
+
{{'item.file.description.format' | translate}}
+
+ {{ fileInput.format }} +
+
{{'item.file.description.description' | translate}}
+
+ {{ fileInput.description }} +
+
{{'item.file.description.checksum' | translate}}
+
+ {{ fileInput.checksum }} +
+
+ Preview +
+ +
+
+
+   + {{'item.file.description.file.preview' | translate}} + +
+
+
    + + + + + +
    {{ fileInput.fileInfo[0]?.content }}
    +
    + + + +
+
+
+
+
diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss new file mode 100644 index 00000000000..2611c696bc7 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss @@ -0,0 +1,125 @@ +.file-preview-box { + border: 1px solid #ddd; + padding: 4px; + margin-bottom: 10px; + + video { + margin-left: 100px; + } + + .file-content { + display: flex !important; + width: 100%; + justify-content: space-between; + + .dl-horizontal { + margin-bottom: 0; + } + + .thumbnails dl { + padding: 5px; + display: table; + } + + @media (min-width: 768px){ + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media (min-width: 768px) { + .dl-horizontal dd { + margin-left: 180px; + } + } + + + .preview-image { + width: 10%; + height: 10%; + } + + } + + .button-container { + a { + text-decoration: none; + } + .download-btn, .preview-btn { + display: inline; + padding: .2em .6em .3em; + font-size: 12px; + font-weight: bold; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; + border: none; + color: white; + cursor: pointer; + background-color: #5bc0de; + } + + .download-btn:hover, .preview-btn:hover { + background-color: #31b0d5; + } + } + + .panel { + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0,0,0,0.05); + } + + .panel-info { + border-color: #bce8f1; + } + + .panel-info>.panel-heading { + color: #3a87ad; + background-color: #d9edf7; + border-color: #bce8f1; + } + + .panel-body { + padding: 15px; + + pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.428571429; + color: #333; + white-space: pre-wrap; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + } + } + + .pull-right { + float: right !important; + } +} + +.dl-horizontal { + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif !important; + font-size: 14px !important; + line-height: 1.428571429 !important; + color: #333 !important; +} + +dd { + margin-bottom: 0; +} + diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts new file mode 100644 index 00000000000..4d1d838faa9 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { FileDescriptionComponent } from './file-description.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { getMockTranslateService } from '../../../../../shared/mocks/translate.service.mock'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../shared/mocks/translate-loader.mock'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; + +describe('FileDescriptionComponent', () => { + let component: FileDescriptionComponent; + let fixture: ComponentFixture; + let translateService: TranslateService; + let halService: HALEndpointService; + + beforeEach(async () => { + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + halService = jasmine.createSpyObj('authService', { + getRootHref: 'root url', + }); + + translateService = getMockTranslateService(); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + declarations: [FileDescriptionComponent], + providers: [ + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: HALEndpointService, useValue: halService } + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDescriptionComponent); + component = fixture.componentInstance; + + // Mock the input value + const fileInput = new MetadataBitstream(); + fileInput.id = 123; + fileInput.name = 'testFile'; + fileInput.description = 'test description'; + fileInput.fileSize = '5MB'; + fileInput.checksum = 'abc'; + fileInput.type = new ResourceType('item'); + fileInput.fileInfo = []; + fileInput.format = 'application/pdf'; + fileInput.canPreview = false; + fileInput._links = { + self: { href: '' }, + schema: { href: '' }, + }; + + component.fileInput = fileInput; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the file name', () => { + const fileNameElement = fixture.debugElement.query( + By.css('.file-content dd') + ).nativeElement; + expect(fileNameElement.textContent).toContain('testFile'); + }); +}); 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 new file mode 100644 index 00000000000..68dd27f4dab --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts @@ -0,0 +1,43 @@ +import { Component, Input } from '@angular/core'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; + +const allowedPreviewFormats = ['text/plain', 'text/html', 'application/zip']; +@Component({ + selector: 'ds-file-description', + templateUrl: './file-description.component.html', + styleUrls: ['./file-description.component.scss'], +}) +export class FileDescriptionComponent { + @Input() + fileInput: MetadataBitstream; + + constructor(protected halService: HALEndpointService) { } + + public downloadFiles() { + console.log('${this.fileInput.href}', `${this.fileInput.href}`); + window.location.href = this.halService.getRootHref().replace('/server/api', '') + `${this.fileInput.href}`; + } + + public isTxt() { + return this.fileInput?.format === 'text/plain'; + } + + /** + * Show scrollbar in the `.txt` preview, but it should be hidden in the other formats. + */ + public dynamicOverflow() { + return this.isTxt() ? 'overflow: scroll' : 'overflow: hidden'; + } + + /** + * Supported Preview formats are: `text/plain`, `text/html`, `application/zip` + */ + public couldPreview() { + if (this.fileInput.canPreview === false) { + return false; + } + + return allowedPreviewFormats.includes(this.fileInput.format); + } +} diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html new file mode 100644 index 00000000000..2b82e44760f --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html @@ -0,0 +1,25 @@ +
  • + + + + {{ node.name }} + + + + + + {{ node.name }} + {{ node.size }} + + +
      + + +
    +
  • diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss new file mode 100644 index 00000000000..0585ae22f29 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss @@ -0,0 +1,31 @@ +.foldername:before { + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #a0a0a0; +} + +.filename:before { + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #a0a0a0; +} + + li { + padding: 0 0 0 8px; + margin: 0; + list-style: none; +} + +.pull-right { + float: right !important; +} + +.foldername a { + cursor: pointer; +} + +.foldername a:hover { + text-decoration: underline; +} diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts new file mode 100644 index 00000000000..1ffa042a5a6 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FileInfo, MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { FileTreeViewComponent } from './file-tree-view.component'; + +describe('FileTreeViewComponent', () => { + let component: FileTreeViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FileTreeViewComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileTreeViewComponent); + component = fixture.componentInstance; + + // Mock the node input value + const fileInfo = new FileInfo(); + fileInfo.name = 'TestFolder'; + fileInfo.isDirectory = true; + fileInfo.size = null; + fileInfo.content = null; // add content property + fileInfo.sub = { + 'TestSubFolder': { + name: 'TestSubFolder', + isDirectory: true, + size: null, + content: null, // add content property + sub: null + } + }; + + const metadataBitstream = new MetadataBitstream(); + metadataBitstream.fileInfo = [fileInfo]; + component.node = fileInfo; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the node name', () => { + waitForAsync(() => { + const nodeNameElement = fixture.debugElement.query(By.css('#folderName')).nativeElement; + expect(nodeNameElement.textContent).toContain('TestFolder'); + }); + }); + + it('should correctly get the keys of the sub object', () => { + expect(component.getKeys(component.node.sub)).toEqual(['TestSubFolder']); + }); +}); diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts new file mode 100644 index 00000000000..96129517885 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { FileInfo } from 'src/app/core/metadata/metadata-bitstream.model'; + +@Component({ + selector: 'ds-file-tree-view', + templateUrl: './file-tree-view.component.html', + styleUrls: ['./file-tree-view.component.scss'], +}) +export class FileTreeViewComponent { + @Input() + node: FileInfo; + + isCollapsed = false; + + getKeys(obj: any): string[] { + return Object.keys(obj); + } +} diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.html b/src/app/item-page/simple/field-components/preview-section/preview-section.component.html new file mode 100644 index 00000000000..8404f491b4f --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.html @@ -0,0 +1,3 @@ +
    + +
    diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss b/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss new file mode 100644 index 00000000000..fb6cb457842 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss @@ -0,0 +1,108 @@ +.file-preview-box { + border: 1px solid #ddd; + padding: 4px; + + .file-content { + display: flex !important; + width: 100%; + justify-content: space-between; + + .dl-horizontal { + margin-bottom: 0; + } + + .thumbnails dl { + padding: 5px; + display: table; + } + + + @media (min-width: 768px){ + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media (min-width: 768px) { + .dl-horizontal dd { + margin-left: 180px; + } + } + + + .preview-image { + width: 10%; + height: 10%; + } + + } + + .button-container { + .download-btn, .preview-btn { + display: inline; + padding: .2em .6em .3em; + font-size: 12px; + font-weight: bold; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; + border: none; + color: white; + cursor: pointer; + background-color: #5bc0de; + } + } + + .panel { + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0,0,0,0.05); + } + + .panel-info { + border-color: #bce8f1; + } + + .panel-info>.panel-heading { + color: #3a87ad; + background-color: #d9edf7; + border-color: #bce8f1; + } + + .treeview .foldername:before { + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #a0a0a0; + } + + .treeview .filename:before { + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #a0a0a0; + } + + .treeview li { + padding: 0 0 0 8px; + margin: 0; + list-style: none; + } + + .pull-right { + float: right !important; + } + + .panel-body { + padding: 15px; + } +} 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 new file mode 100644 index 00000000000..8258c340d86 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; + +import { PreviewSectionComponent } from './preview-section.component'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { HALLink } from 'src/app/core/shared/hal-link.model'; +import { Item } from 'src/app/core/shared/item.model'; + +describe('PreviewSectionComponent', () => { + let component: PreviewSectionComponent; + let fixture: ComponentFixture; + let mockRegistryService: any; + + beforeEach(async () => { + mockRegistryService = jasmine.createSpyObj('RegistryService', [ + 'getMetadataBitstream', + ]); + + await TestBed.configureTestingModule({ + declarations: [PreviewSectionComponent], + providers: [{ provide: RegistryService, useValue: mockRegistryService }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PreviewSectionComponent); + component = fixture.componentInstance; + + // Set up the mock service's getMetadataBitstream method to return a simple stream + const metadatabitstream = new MetadataBitstream(); + metadatabitstream.id = 123; + metadatabitstream.name = 'test'; + metadatabitstream.description = 'test'; + metadatabitstream.fileSize = '1MB'; + metadatabitstream.checksum = 'abc'; + metadatabitstream.type = new ResourceType('item'); + metadatabitstream.fileInfo = []; + metadatabitstream.format = 'text'; + metadatabitstream.canPreview = false; + metadatabitstream._links = { + self: new HALLink(), + schema: new HALLink(), + }; + + metadatabitstream._links.self.href = ''; + metadatabitstream._links.schema.href = ''; + const metadataBitstreams: MetadataBitstream[] = [metadatabitstream]; + const bitstreamStream = new BehaviorSubject(metadataBitstreams); + mockRegistryService.getMetadataBitstream.and.returnValue( + of(bitstreamStream) + ); + + component.item = new Item(); + component.item.handle = '12345'; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call getMetadataBitstream on init', () => { + expect(mockRegistryService.getMetadataBitstream).toHaveBeenCalled(); + }); + + it('should set listOfFiles on init', (done) => { + component.listOfFiles.subscribe((files) => { + expect(files).toEqual([]); + done(); + }); + }); +}); 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 new file mode 100644 index 00000000000..ac8b5df4dd4 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts @@ -0,0 +1,28 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Item } from 'src/app/core/shared/item.model'; +import { getAllSucceededRemoteListPayload } from 'src/app/core/shared/operators'; + +@Component({ + selector: 'ds-preview-section', + templateUrl: './preview-section.component.html', + styleUrls: ['./preview-section.component.scss'], +}) +export class PreviewSectionComponent implements OnInit { + @Input() item: Item; + + listOfFiles: BehaviorSubject = new BehaviorSubject([] as any); + + constructor(protected registryService: RegistryService) {} // Modified + + ngOnInit(): void { + this.registryService + .getMetadataBitstream(this.item.handle, 'ORIGINAL,TEXT,THUMBNAIL') + .pipe(getAllSucceededRemoteListPayload()) + .subscribe((data: MetadataBitstream[]) => { + this.listOfFiles.next(data); + }); + } +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index fc4aaeffc17..8d2c9048344 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -11,6 +11,28 @@ +
     {{'item.page.files.head' | translate}}
    + + diff --git a/src/app/item-page/simple/item-page.component.scss b/src/app/item-page/simple/item-page.component.scss index 24ab7394c3e..5e419c675d0 100644 --- a/src/app/item-page/simple/item-page.component.scss +++ b/src/app/item-page/simple/item-page.component.scss @@ -28,3 +28,58 @@ margin-top: -16px; padding-top: 16px; } + +.btn-download{ + color: #fff !important; + background-color: #428bca; + border-color: #357ebd; + cursor: pointer; +} + +.btn-download:hover { + color: #fff; + background-color: #3276b1; + border-color: #285e8e; +} + +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.428571429; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + white-space: pre-wrap; +} + +#command-div .repo-copy-btn { + opacity: 0; + -webkit-transition: opacity .3s ease-in-out; + -o-transition: opacity .3s ease-in-out; + transition: opacity .3s ease-in-out; +} + +.repo-copy-btn { + width: 20px; + height: 20px; + position: relative; + display: inline-block; + padding: 0 !important; +} + +.repo-copy-btn:before { + content: " "; + background-image: url('https://lindat.mff.cuni.cz/repository/xmlui/themes/UFAL/images/clippy.svg'); + background-size: 13px 15px; + background-repeat: no-repeat; + background-color: red; + display: inline-block; + width: 13px; + height: 15px; + padding: 0 !important; +} diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 9b0e87939df..8c0bb70cdda 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; @@ -22,6 +22,15 @@ import { import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from 'src/app/core/data/metadata-bitstream-data.service'; +import { getMockTranslateService } from 'src/app/shared/mocks/translate.service.mock'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -40,6 +49,12 @@ describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; + let translateService: TranslateService; + let registryService: RegistryService; + let halService: HALEndpointService; + const authorizationService = jasmine.createSpyObj('authorizationService', [ + 'isAuthorized', + ]); let authorizationDataService: AuthorizationDataService; const mockMetadataService = { @@ -52,15 +67,34 @@ describe('ItemPageComponent', () => { data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }) }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => observableOf({}) // Returns a mock Observable + }; + beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), setRedirectUrl: {} }); + + translateService = getMockTranslateService(); authorizationDataService = jasmine.createSpyObj('authorizationDataService', { isAuthorized: observableOf(false), }); + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + halService = jasmine.createSpyObj('authService', { + getRootHref: 'root url', + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -75,7 +109,15 @@ describe('ItemPageComponent', () => { { provide: MetadataService, useValue: mockMetadataService }, { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Store, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: MetadataSchemaDataService, useValue: {} }, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: MetadataBitstreamDataService, useValue: mockMetadataBitstreamDataService }, + RegistryService, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: HALEndpointService, useValue: halService } ], schemas: [NO_ERRORS_SCHEMA] @@ -85,6 +127,7 @@ describe('ItemPageComponent', () => { })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(ItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index a4f9eb3766c..f9768ea75a0 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,20 +1,25 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; - -import { Observable} from 'rxjs'; import { map, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { fadeInOut } from '../../shared/animations/fade'; -import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { + getAllSucceededRemoteDataPayload, + getAllSucceededRemoteListPayload, +} from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; import { AuthService } from '../../core/auth/auth.service'; import { getItemPageRoute } from '../item-page-routing-paths'; -import { isNotEmpty } from '../../shared/empty.util'; +import { isNotEmpty} from '../../shared/empty.util'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { Observable} from 'rxjs'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; /** * This component renders a simple item page. @@ -49,6 +54,22 @@ export class ItemPageComponent implements OnInit { * Route to the item's page */ itemPageRoute$: Observable; + /** + * handle of the specific item + */ + itemHandle: string; + /** + * handle of the specific item + */ + fileName: string; + /** + * determine to show download all zip button or not + */ + canDownloadAllFiles = false; + /** + * command for the download command feature + */ + command: string; /** * Whether the current user is an admin or not @@ -65,14 +86,31 @@ export class ItemPageComponent implements OnInit { */ withdrawnTombstone = false; + /** + * If download by command button is click, the command line will be shown + */ + isCommandLineVisible = false; + /** + * list of files uploaded by users to this item + */ + listOfFiles: MetadataBitstream[]; + /** + * total size of list of files uploaded by users to this item + */ + totalFileSizes: string; + itemUrl: string; + canShowCurlDownload = false; + constructor( protected route: ActivatedRoute, private router: Router, private items: ItemDataService, private authService: AuthService, - private authorizationService: AuthorizationDataService + private authorizationService: AuthorizationDataService, + protected registryService: RegistryService, + protected halService: HALEndpointService, ) { } @@ -90,6 +128,43 @@ export class ItemPageComponent implements OnInit { ); this.showTombstone(); + + this.registryService + .getMetadataBitstream(this.itemHandle, 'ORIGINAL,TEXT,THUMBNAIL') + .pipe(getAllSucceededRemoteListPayload()) + .subscribe((data: MetadataBitstream[]) => { + this.listOfFiles = data; + this.generateCurlCommand(); + this.sumFileSizes(); + }); + } + + sumFileSizes() { + const sizeUnits = { + B: 1, + KB: 1000, + MB: 1000 * 1000, + GB: 1000 * 1000 * 1000, + TB: 1000 * 1000 * 1000 * 1000, + }; + + let totalBytes = this.listOfFiles.reduce((total, file) => { + const [valueStr, unit] = file.fileSize.split(' '); + const value = parseFloat(valueStr); + const bytes = value * sizeUnits[unit.toUpperCase()]; + return total + bytes; + }, 0); + + let finalUnit = 'B'; + for (const unit of ['KB', 'MB', 'GB', 'TB']) { + if (totalBytes < 1000) { + break; + } + totalBytes /= 1000; + finalUnit = unit; + } + + this.totalFileSizes = totalBytes.toFixed(2) + ' ' + finalUnit; } showTombstone() { @@ -103,6 +178,7 @@ export class ItemPageComponent implements OnInit { take(1), getAllSucceededRemoteDataPayload()) .subscribe((item: Item) => { + this.itemHandle = item.handle; isWithdrawn = item.isWithdrawn; isReplaced = item.metadata['dc.relation.isreplacedby']?.[0]?.value; }); @@ -126,4 +202,33 @@ export class ItemPageComponent implements OnInit { } }); } + + setCommandline() { + this.isCommandLineVisible = !this.isCommandLineVisible; + } + + generateCurlCommand() { + const fileNames = this.listOfFiles.map((file: MetadataBitstream) => { + // Show `Download All Files` only if there are more files. + if (file.canPreview && this.listOfFiles.length > 1) { + this.canDownloadAllFiles = file.canPreview; + } + + if (file.canPreview) { + this.canShowCurlDownload = true; + } + + return file.name; + }); + + this.command = `curl --remote-name-all ` + this.halService.getRootHref() + `/core/bitstreams/handle/${ + this.itemHandle + }/{${fileNames.join(',')}}`; + } + + downloadFiles() { + window.location.href = this.halService.getRootHref() + `/core/bitstreams/allzip?handleId=${this.itemHandle}`; + } + + } diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index ea866af5e01..c6f00dafe22 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -95,9 +95,9 @@ {{"item.page.link.full" | translate}} - - + + + + diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index ae2cde511d6..17f3675f944 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -101,9 +101,9 @@ {{"item.page.link.full" | translate}} - - + + + + diff --git a/src/app/submission/sections/clarin-license-resource/ufal-theme.css b/src/app/submission/sections/clarin-license-resource/ufal-theme.css index 8bb5d65fd7e..b340bee4658 100644 --- a/src/app/submission/sections/clarin-license-resource/ufal-theme.css +++ b/src/app/submission/sections/clarin-license-resource/ufal-theme.css @@ -1427,17 +1427,17 @@ div.modal-scrollbar { } .treeview .foldername:before { - font-family: FontAwesome; - content: '\f07b'; - margin-right: 5px; - color: #A0A0A0; + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #A0A0A0; } .treeview .filename:before { - font-family: FontAwesome; - content: '\f016'; - margin-right: 5px; - color: #A0A0A0; + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #A0A0A0; } .filebutton { diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 88be6f7737a..049b7911501 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -5126,6 +5126,43 @@ "item.view.box.no-files.message": "Tento záznam neobsahuje soubory.", +// "item.file.description.not.supported.video": "Your browser does not support the video tag.", + "item.file.description.not.supported.video": "Váš prohlížeč nepodporuje videa.", + +// "item.file.description.name": "Name", + "item.file.description.name": "Název", + +// "item.file.description.size": "Size", + "item.file.description.size": "Velikost", + +// "item.file.description.format": "Format", + "item.file.description.format": "Formát", + +// "item.file.description.description": "Description", + "item.file.description.description": "Popis", + +// "item.file.description.checksum": "MD5", + "item.file.description.checksum": "MD5", + +// "item.file.description.download.file": "Download file", + "item.file.description.download.file": "Stáhnout soubor", + +// "item.file.description.preview": "Preview", + "item.file.description.preview": "Náhled", + +// "item.file.description.file.preview": "File Preview", + "item.file.description.file.preview": "Náhled souboru", + + // "item.page.files.head": "Files in this item", + "item.page.files.head": "Soubory tohoto záznamu", + + // "item.page.download.button.command.line": "Download instructions for command line", + "item.page.download.button.command.line": "Instrukce pro stažení z příkazové řádky", + + // "item.page.download.button.all.files.zip": "Download all files in item", + "item.page.download.button.all.files.zip": "Stáhnout všechny soubory záznamu", + + // "item.select.confirm" : "Confirm selected" "item.select.confirm" : "Potvrdit vybrané", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index f3011292707..63392b829c3 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2776,6 +2776,31 @@ "item.view.box.no-files.message": "This item contains no files.", + "item.file.description.not.supported.video": "Your browser does not support the video tag.", + + "item.file.description.name": "Name", + + "item.file.description.size": "Size", + + "item.file.description.format": "Format", + + "item.file.description.description": "Description", + + "item.file.description.checksum": "MD5", + + "item.file.description.download.file": "Download file", + + "item.file.description.preview": "Preview", + + "item.file.description.file.preview": "File Preview", + + "item.page.files.head": "Files in this item", + + "item.page.download.button.command.line": "Download instructions for command line", + + "item.page.download.button.all.files.zip": "Download all files in item", + + "item.select.confirm": "Confirm selected", "item.select.empty": "No items to show", diff --git a/src/assets/images/application-x-gzip.png b/src/assets/images/application-x-gzip.png new file mode 100644 index 00000000000..be9a74a89d4 Binary files /dev/null and b/src/assets/images/application-x-gzip.png differ