diff --git a/src/app/core/handle/HandleResourceTypeIdserializer.ts b/src/app/core/handle/HandleResourceTypeIdserializer.ts index 8e2f765c018..e88af9af483 100644 --- a/src/app/core/handle/HandleResourceTypeIdserializer.ts +++ b/src/app/core/handle/HandleResourceTypeIdserializer.ts @@ -14,6 +14,8 @@ export const HandleResourceTypeIdSerializer = { return 3; case COMMUNITY: return 4; + case SITE: + return 5; default: return null; } @@ -27,8 +29,10 @@ export const HandleResourceTypeIdSerializer = { return COLLECTION; case 4: return COMMUNITY; - default: + case 5: return SITE; + default: + return null; } } }; diff --git a/src/app/core/handle/handle.resource-type.ts b/src/app/core/handle/handle.resource-type.ts index f4728c150be..61e155c770b 100644 --- a/src/app/core/handle/handle.resource-type.ts +++ b/src/app/core/handle/handle.resource-type.ts @@ -9,6 +9,7 @@ import { ResourceType } from '../shared/resource-type'; export const HANDLE = new ResourceType('handle'); export const SUCCESSFUL_RESPONSE_START_CHAR = '2'; +export const INVALID_RESOURCE_TYPE_ID = -1; export const COMMUNITY = 'Community'; export const COLLECTION = 'Collection'; export const ITEM = 'Item'; diff --git a/src/app/handle-page/handle-table/handle-table.component.html b/src/app/handle-page/handle-table/handle-table.component.html index ae1d34ee202..553992d3b90 100644 --- a/src/app/handle-page/handle-table/handle-table.component.html +++ b/src/app/handle-page/handle-table/handle-table.component.html @@ -11,7 +11,7 @@
{{ 'handle-table.title' | translate }}
class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> - {{searchOption}} + {{searchOption}} {{'handle-table.dropdown.search-option' | translate}}
@@ -20,9 +20,53 @@
{{ 'handle-table.title' | translate }}
- + + +
+ + + + + + + +
+ + +
+ + +
+ + +
+
+ @@ -70,7 +114,7 @@
{{ 'handle-table.title' | translate }}
- {{handle?.resourceTypeID}} + {{getTranslatedResourceType(handle?.resourceTypeID)}} diff --git a/src/app/handle-page/handle-table/handle-table.component.scss b/src/app/handle-page/handle-table/handle-table.component.scss index d1e780d255e..a2bf95ccb9b 100644 --- a/src/app/handle-page/handle-table/handle-table.component.scss +++ b/src/app/handle-page/handle-table/handle-table.component.scss @@ -1,3 +1,48 @@ /** - The file for styling `handle-table.component.html`. No styling needed. + * Styling for handle-table component search functionality */ + +// Make select elements look like buttons to match previous dropdown styling +.search-input-container { + .select-wrapper { + position: relative; + display: inline-block; + width: 100%; + } + + .form-select { + background: white; + border: 1px solid #ced4da; + border-radius: 0.25rem; + padding: 0.375rem 2.5rem 0.375rem 0.75rem; + color: #495057; + appearance: none; + cursor: pointer; + + &:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: 0; + } + + &:disabled { + background-color: #f8f9fa; + color: #6c757d; + cursor: not-allowed; + } + } + + .select-icon { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #495057; + font-size: 0.875rem; + } + + .form-control { + border-radius: 0; + } +} diff --git a/src/app/handle-page/handle-table/handle-table.component.ts b/src/app/handle-page/handle-table/handle-table.component.ts index 2c86e160e0c..2cd0d35965e 100644 --- a/src/app/handle-page/handle-table/handle-table.component.ts +++ b/src/app/handle-page/handle-table/handle-table.component.ts @@ -24,6 +24,7 @@ import { Handle } from '../../core/handle/handle.model'; import { COLLECTION, COMMUNITY, + INVALID_RESOURCE_TYPE_ID, ITEM, SITE, SUCCESSFUL_RESPONSE_START_CHAR @@ -31,6 +32,7 @@ import { import { getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths'; import { getEntityPageRoute } from '../../item-page/item-page-routing-paths'; +import { HandleResourceTypeIdSerializer } from '../../core/handle/HandleResourceTypeIdserializer'; /** * Constants for converting the searchQuery for the server @@ -359,6 +361,47 @@ export class HandleTableComponent implements OnInit { */ setSearchOption(event) { this.searchOption = event?.target?.innerHTML; + // Reset search query when changing search option + this.searchQuery = ''; + } + + /** + * Get translated resource type name for table display + * Converts constants like 'Community', 'Collection', 'Item', 'Site' to translated strings + */ + getTranslatedResourceType(resourceTypeID: string): string { + if (!resourceTypeID) { + return ''; + } + + // Map the constant values to lowercase for translation keys + const resourceTypeKey = resourceTypeID.toLowerCase(); + const translationKey = `handle-table.search.resource-type.${resourceTypeKey}`; + + // Return translated value, fallback to original if translation not found + const translated = this.translateService.instant(translationKey); + return translated !== translationKey ? translated : resourceTypeID; + } + + /** + * Parse internal search query to server format + */ + private parseInternalSearchQuery(searchQuery: string): string { + const normalizedQuery = searchQuery.toLowerCase(); + if (normalizedQuery === 'yes') { + return 'internal'; + } else if (normalizedQuery === 'no') { + return 'external'; + } + return searchQuery; + } + + /** + * Parse resource type search query to server format (converts to numeric ID) + */ + private parseResourceTypeSearchQuery(searchQuery: string): string { + const id = HandleResourceTypeIdSerializer.Serialize(searchQuery); + return id ? id.toString() : INVALID_RESOURCE_TYPE_ID.toString(); } /** @@ -381,35 +424,12 @@ export class HandleTableComponent implements OnInit { parsedSearchOption = HANDLE_SEARCH_OPTION; break; case this.internalOption: - // if the handle doesn't have the URL - is internal, if it does - is external parsedSearchOption = URL_SEARCH_OPTION; - if (this.searchQuery.toLowerCase() === 'yes') { - parsedSearchQuery = 'internal'; - } else if (this.searchQuery.toLowerCase() === 'no') { - parsedSearchQuery = 'external'; - } + parsedSearchQuery = this.parseInternalSearchQuery(this.searchQuery); break; case this.resourceTypeOption: parsedSearchOption = RESOURCE_TYPE_SEARCH_OPTION; - // parse resourceType from string to the number because the resourceType is integer on the server - switch (this.searchQuery.toLowerCase()) { - case ITEM.toLowerCase(): - parsedSearchQuery = '' + 2; - break; - case COLLECTION.toLowerCase(): - parsedSearchQuery = '' + 3; - break; - case COMMUNITY.toLowerCase(): - parsedSearchQuery = '' + 4; - break; - case SITE.toLowerCase(): - parsedSearchQuery = '' + 5; - break; - // no results for invalid search inputs - default: - parsedSearchQuery = '' + -1; - break; - } + parsedSearchQuery = this.parseResourceTypeSearchQuery(this.searchQuery); break; } } diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 18bbc4fd606..bc6d6879332 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -90,6 +90,8 @@ import { ClarinIdentifierItemFieldComponent } from './simple/field-components/cl import { ClarinDateItemFieldComponent } from './simple/field-components/clarin-date-item-field/clarin-date-item-field.component'; import { ClarinDescriptionItemFieldComponent } from './simple/field-components/clarin-description-item-field/clarin-description-item-field.component'; import { ClarinFilesSectionComponent } from './clarin-files-section/clarin-files-section.component'; +import { UsageReportDataService } from '../core/statistics/usage-report-data.service'; +import { CreativeCommonsLicenseFieldComponent } from './simple/field-components/creative-commons-license-field/creative-commons-license-field.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -154,7 +156,8 @@ const DECLARATIONS = [ ClarinIdentifierItemFieldComponent, ClarinDateItemFieldComponent, ClarinDescriptionItemFieldComponent, - ClarinFilesSectionComponent + ClarinFilesSectionComponent, + CreativeCommonsLicenseFieldComponent ]; @NgModule({ diff --git a/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.html b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.html new file mode 100644 index 00000000000..21566fb02aa --- /dev/null +++ b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.html @@ -0,0 +1,66 @@ +
+
+
+
{{ 'item.page.cc-license' | translate }}
+
+
+
+ + + + + +
+
+
diff --git a/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.scss b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.scss new file mode 100644 index 00000000000..57e9f8102e0 --- /dev/null +++ b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.scss @@ -0,0 +1,113 @@ +@import '../../item-page.component'; + +@media (min-width: 992px) { + .col-2-5 { + flex: 0 0 20%; + max-width: 20%; + } +} + +.clarin-item-page-field { + .cc-license-content { + .cc-license-row { + display: flex; + flex-direction: column; + gap: 4px; + } + + .cc-license-link { + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #007bff; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + color: #0056b3; + text-decoration: none; + } + + .cc-icons { + display: flex; + align-items: center; + gap: 2px; + + i { + font-size: 1.1rem; + + &.fa-creative-commons { + color: #f39c12; + } + + &.fa-creative-commons-by { + color: #3498db; + } + + &.fa-creative-commons-sa { + color: #27ae60; + } + + &.fa-creative-commons-nc { + color: #e74c3c; + } + + &.fa-creative-commons-nd { + color: #9b59b6; + } + + &.fa-creative-commons-zero { + color: #34495e; + } + } + } + + .cc-license-text { + font-size: 0.95rem; + font-weight: 500; + } + + &:hover .cc-icons i { + font-size: 1.2rem; + } + } + + .cc-license-description { + font-size: 0.85rem; + color: #6c757d; + font-style: italic; + margin-left: 0; + } + } +} + +// Responsive design +@media (max-width: 991.98px) { + .clarin-item-page-field { + .cc-license-content { + .cc-license-link { + flex-direction: column; + align-items: flex-start; + gap: 4px; + + .cc-icons { + gap: 1px; + + i { + font-size: 1rem; + } + } + + .cc-license-text { + font-size: 0.9rem; + } + } + + .cc-license-description { + font-size: 0.8rem; + margin-top: 2px; + } + } + } +} diff --git a/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.spec.ts b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.spec.ts new file mode 100644 index 00000000000..a89dd992f0a --- /dev/null +++ b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.spec.ts @@ -0,0 +1,384 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { CreativeCommonsLicenseFieldComponent } from './creative-commons-license-field.component'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Bundle } from '../../../../core/shared/bundle.model'; +import { PaginatedList, buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { createSuccessfulRemoteDataObject, createFailedRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +describe('CreativeCommonsLicenseFieldComponent', () => { + let component: CreativeCommonsLicenseFieldComponent; + let fixture: ComponentFixture; + let bundleDataService: jasmine.SpyObj; + let bitstreamDataService: jasmine.SpyObj; + + const mockItem = { + uuid: 'test-item-uuid', + metadata: { + 'dc.rights.uri': [{ value: 'https://creativecommons.org/licenses/by/4.0/' }], + 'dc.rights': [{ value: 'Attribution 4.0 International' }] + }, + allMetadata: jasmine.createSpy('allMetadata').and.callFake((field: string) => { + return mockItem.metadata[field] || []; + }) + } as any as Item; + + const mockItemWithoutLicense = { + uuid: 'test-item-uuid-no-license', + metadata: { + 'dc.title': [{ value: 'Test Item' }] + }, + allMetadata: jasmine.createSpy('allMetadata').and.callFake((field: string) => { + return mockItemWithoutLicense.metadata[field] || []; + }) + } as any as Item; + + const mockBundle = { + uuid: 'test-bundle-uuid', + name: 'CC_LICENSE' + } as Bundle; + + const mockBitstream = { + uuid: 'test-bitstream-uuid', + name: 'license.txt', + metadata: { + 'dc.identifier.uri': [{ value: 'https://creativecommons.org/licenses/by-nc-sa/3.0/' }] + } + } as any as Bitstream; + + beforeEach(async () => { + const bundleDataServiceSpy = jasmine.createSpyObj('BundleDataService', ['findByItemAndName']); + const bitstreamDataServiceSpy = jasmine.createSpyObj('BitstreamDataService', ['findAllByItemAndBundleName']); + + await TestBed.configureTestingModule({ + declarations: [CreativeCommonsLicenseFieldComponent], + imports: [ + TranslateModule.forRoot(), + NoopAnimationsModule + ], + providers: [ + { provide: BundleDataService, useValue: bundleDataServiceSpy }, + { provide: BitstreamDataService, useValue: bitstreamDataServiceSpy } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CreativeCommonsLicenseFieldComponent); + component = fixture.componentInstance; + bundleDataService = TestBed.inject(BundleDataService) as jasmine.SpyObj; + bitstreamDataService = TestBed.inject(BitstreamDataService) as jasmine.SpyObj; + }); + + describe('Component Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with item input', () => { + component.item = mockItem; + expect(component.item).toBe(mockItem); + }); + }); + + describe('Creative Commons License Detection', () => { + beforeEach(() => { + component.item = mockItem; + }); + + it('should detect Creative Commons license from metadata', (done) => { + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.hasCcLicense$.subscribe(hasLicense => { + expect(hasLicense).toBe(true); + done(); + }); + }); + + it('should detect Creative Commons license from CC_LICENSE bundle', (done) => { + bundleDataService.findByItemAndName.and.returnValue(of(createSuccessfulRemoteDataObject(mockBundle))); + bitstreamDataService.findAllByItemAndBundleName.and.returnValue( + of(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [mockBitstream]))) + ); + + component.ngOnInit(); + + component.hasCcLicense$.subscribe(hasLicense => { + expect(hasLicense).toBe(true); + done(); + }); + }); + + it('should return false when no Creative Commons license is found', (done) => { + component.item = mockItemWithoutLicense; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.hasCcLicense$.subscribe(hasLicense => { + expect(hasLicense).toBe(false); + done(); + }); + }); + }); + + describe('License URL Extraction', () => { + beforeEach(() => { + component.item = mockItem; + }); + + it('should extract license URL from metadata', (done) => { + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.ccLicenseUrl$.subscribe(url => { + expect(url).toBe('https://creativecommons.org/licenses/by/4.0/'); + done(); + }); + }); + + it('should extract license URL from bitstream metadata', (done) => { + bundleDataService.findByItemAndName.and.returnValue(of(createSuccessfulRemoteDataObject(mockBundle))); + bitstreamDataService.findAllByItemAndBundleName.and.returnValue( + of(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [mockBitstream]))) + ); + + component.ngOnInit(); + + component.ccLicenseUrl$.subscribe(url => { + expect(url).toBe('https://creativecommons.org/licenses/by-nc-sa/3.0/'); + done(); + }); + }); + + it('should return empty string when no license URL is found', (done) => { + component.item = mockItemWithoutLicense; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.ccLicenseUrl$.subscribe(url => { + expect(url).toBe(''); + done(); + }); + }); + }); + + describe('License Name Extraction', () => { + beforeEach(() => { + component.item = mockItem; + }); + + it('should extract license name from URL', (done) => { + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.ccLicenseName$.subscribe(name => { + expect(name).toBe('CC BY 4.0'); + done(); + }); + }); + + it('should return empty string when no license URL is available', (done) => { + component.item = mockItemWithoutLicense; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.ccLicenseName$.subscribe(name => { + expect(name).toBe(''); + done(); + }); + }); + }); + + describe('License Type Detection', () => { + const testCases = [ + { input: 'CC BY 4.0', expected: 'by' }, + { input: 'CC BY-SA 3.0', expected: 'by-sa' }, + { input: 'CC BY-NC 2.0', expected: 'by-nc' }, + { input: 'CC BY-NC-SA 4.0', expected: 'by-nc-sa' }, + { input: 'CC BY-ND 3.0', expected: 'by-nd' }, + { input: 'CC BY-NC-ND 2.0', expected: 'by-nc-nd' }, + { input: 'CC0 1.0', expected: 'cc0' }, + { input: 'Public Domain Mark', expected: 'cc0' }, + { input: '', expected: '' }, + { input: 'Unknown License', expected: '' } + ]; + + testCases.forEach(testCase => { + it(`should return '${testCase.expected}' for license '${testCase.input}'`, () => { + const result = component.getLicenseType(testCase.input); + expect(result).toBe(testCase.expected); + }); + }); + }); + + describe('License Name Extraction from URL', () => { + const urlTestCases = [ + { + url: 'https://creativecommons.org/licenses/by/4.0/', + expected: 'CC BY 4.0' + }, + { + url: 'https://creativecommons.org/licenses/by-sa/3.0/', + expected: 'CC BY-SA 3.0' + }, + { + url: 'https://creativecommons.org/licenses/by-nc/2.0/', + expected: 'CC BY-NC 2.0' + }, + { + url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/', + expected: 'CC BY-NC-SA 4.0' + }, + { + url: 'https://creativecommons.org/licenses/by-nd/3.0/', + expected: 'CC BY-ND 3.0' + }, + { + url: 'https://creativecommons.org/licenses/by-nc-nd/2.0/', + expected: 'CC BY-NC-ND 2.0' + }, + { + url: 'https://creativecommons.org/publicdomain/zero/1.0/', + expected: 'CC0 1.0' + }, + { + url: 'https://creativecommons.org/publicdomain/mark/1.0/', + expected: 'Public Domain 1.0' + }, + { + url: 'https://example.com/not-cc-license', + expected: '' + }, + { + url: '', + expected: '' + } + ]; + + urlTestCases.forEach(testCase => { + it(`should extract '${testCase.expected}' from URL '${testCase.url}'`, () => { + const result = component['extractLicenseNameFromUrl'](testCase.url); + expect(result).toBe(testCase.expected); + }); + }); + }); + + describe('Metadata Extraction', () => { + it('should extract Creative Commons URL from dc.rights.uri', () => { + component.item = mockItem; + const result = component['extractUrlFromMetadata'](); + expect(result).toBe('https://creativecommons.org/licenses/by/4.0/'); + }); + + it('should return empty string when no Creative Commons URL is found in metadata', () => { + component.item = mockItemWithoutLicense; + const result = component['extractUrlFromMetadata'](); + expect(result).toBe(''); + }); + + it('should check multiple metadata fields for Creative Commons URL', () => { + const itemWithDifferentField = { + uuid: 'test-item-uuid', + metadata: { + 'dc.rights': [{ value: 'https://creativecommons.org/licenses/by-sa/3.0/' }] + }, + allMetadata: jasmine.createSpy('allMetadata').and.callFake((field: string) => { + return itemWithDifferentField.metadata[field] || []; + }) + } as any as Item; + + component.item = itemWithDifferentField; + const result = component['extractUrlFromMetadata'](); + expect(result).toBe('https://creativecommons.org/licenses/by-sa/3.0/'); + }); + }); + + describe('Component Template Integration', () => { + it('should not display license field when no license is present', () => { + component.item = mockItemWithoutLicense; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + fixture.detectChanges(); + + const licenseElement = fixture.debugElement.query(By.css('.clarin-item-page-field')); + expect(licenseElement).toBeNull(); + }); + + it('should display license field when Creative Commons license is present', () => { + component.item = mockItem; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + fixture.detectChanges(); + + setTimeout(() => { + fixture.detectChanges(); + const licenseElement = fixture.debugElement.query(By.css('.clarin-item-page-field')); + expect(licenseElement).toBeTruthy(); + }, 100); + }); + }); + + describe('Error Handling', () => { + it('should handle bundle service errors gracefully', (done) => { + component.item = mockItem; + bundleDataService.findByItemAndName.and.returnValue(of(createFailedRemoteDataObject())); + + component.ngOnInit(); + + component.hasCcLicense$.subscribe(hasLicense => { + expect(hasLicense).toBe(true); // Should still detect from metadata + done(); + }); + }); + + it('should handle bitstream service errors gracefully', (done) => { + component.item = mockItem; + bundleDataService.findByItemAndName.and.returnValue(of(createSuccessfulRemoteDataObject(mockBundle))); + bitstreamDataService.findAllByItemAndBundleName.and.returnValue(of(createFailedRemoteDataObject>())); + + component.ngOnInit(); + + component.ccLicenseUrl$.subscribe(url => { + expect(url).toBe('https://creativecommons.org/licenses/by/4.0/'); // Fallback to metadata + done(); + }); + }); + }); + + describe('Debug Methods', () => { + it('should return metadata values for debug purposes', () => { + component.item = mockItem; + const result = component.getMetadataValues('dc.rights.uri'); + expect(result).toBe('https://creativecommons.org/licenses/by/4.0/'); + }); + + it('should return "No values found" when metadata field does not exist', () => { + component.item = mockItem; + const result = component.getMetadataValues('nonexistent.field'); + expect(result).toBe('No values found'); + }); + + it('should return "No metadata available" when item has no metadata', () => { + component.item = { metadata: null } as any as Item; + const result = component.getMetadataValues('dc.rights.uri'); + expect(result).toBe('No metadata available'); + }); + }); +}); diff --git a/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.ts b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.ts new file mode 100644 index 00000000000..fec3ac0ca1d --- /dev/null +++ b/src/app/item-page/simple/field-components/creative-commons-license-field/creative-commons-license-field.component.ts @@ -0,0 +1,257 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { map, switchMap, catchError } from 'rxjs/operators'; +import { Item } from '../../../../core/shared/item.model'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Bundle } from '../../../../core/shared/bundle.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; + +/** + * Constants for Creative Commons license handling + */ +const CC_CONSTANTS = { + BUNDLE_NAME: 'CC_LICENSE', + DOMAIN: 'creativecommons.org', + METADATA_FIELDS: [ + 'dc.rights.uri', + 'dc.rights', + 'dc.identifier.uri' + ], + LICENSE_TYPES: { + 'by/': 'CC BY', + 'by-sa/': 'CC BY-SA', + 'by-nc/': 'CC BY-NC', + 'by-nc-sa/': 'CC BY-NC-SA', + 'by-nd/': 'CC BY-ND', + 'by-nc-nd/': 'CC BY-NC-ND', + 'publicdomain/zero/': 'CC0', + 'publicdomain/mark/': 'Public Domain' + }, + LICENSE_TYPE_PATTERNS: { + CC0: ['cc0', 'public domain'], + BY_NC_ND: ['by-nc-nd'], + BY_NC_SA: ['by-nc-sa'], + BY_NC: ['by-nc'], + BY_ND: ['by-nd'], + BY_SA: ['by-sa'], + BY: ['by'] + }, + ICON_CLASSES: { + BASE: 'fab fa-creative-commons', + BY: 'fab fa-creative-commons-by', + SA: 'fab fa-creative-commons-sa', + NC: 'fab fa-creative-commons-nc', + ND: 'fab fa-creative-commons-nd', + ZERO: 'fab fa-creative-commons-zero' + }, + TEMPLATE_SWITCH_CASES: { + CC0: 'cc0', + BY_NC_ND: 'by-nc-nd', + BY_NC_SA: 'by-nc-sa', + BY_NC: 'by-nc', + BY_ND: 'by-nd', + BY_SA: 'by-sa', + BY: 'by' + }, + DEFAULT_MESSAGES: { + NO_METADATA: 'No metadata available', + NO_VALUES: 'No values found', + DEFAULT_LICENSE: 'Creative Commons License' + } +} as const; + +/** + * Component for displaying Creative Commons license information on item pages + */ +@Component({ + selector: 'ds-creative-commons-license-field', + templateUrl: './creative-commons-license-field.component.html', + styleUrls: ['./creative-commons-license-field.component.scss'] +}) +export class CreativeCommonsLicenseFieldComponent implements OnInit { + + /** + * The item to display Creative Commons license for + */ + @Input() item: Item; + + /** + * Creative Commons license URL + */ + ccLicenseUrl$: Observable; + + /** + * Creative Commons license name + */ + ccLicenseName$: Observable; + + /** + * Whether the item has a Creative Commons license + */ + hasCcLicense$: Observable; + + constructor( + private bundleService: BundleDataService, + private bitstreamService: BitstreamDataService + ) { } + + ngOnInit(): void { + this.initializeCcLicense(); + } + + /** + * Initialize Creative Commons license information + */ + private initializeCcLicense(): void { + // Cache the metadata URL extraction to avoid repeated calls + const metadataUrl = this.extractUrlFromMetadata(); + + // Check if item has CC_LICENSE bundle and extract license information + const ccLicenseBundle$ = this.bundleService.findByItemAndName(this.item, CC_CONSTANTS.BUNDLE_NAME); + + this.hasCcLicense$ = ccLicenseBundle$.pipe( + map((bundleRD: RemoteData) => { + // Check if CC_LICENSE bundle exists OR if CC license metadata exists + const hasBundleLicense = bundleRD.hasSucceeded && isNotEmpty(bundleRD.payload); + const hasMetadataLicense = isNotEmpty(metadataUrl); + return hasBundleLicense || hasMetadataLicense; + }), + catchError(() => of(false)) + ); + + // Get the license URL from bitstreams in CC_LICENSE bundle + this.ccLicenseUrl$ = ccLicenseBundle$.pipe( + switchMap((bundleRD: RemoteData) => { + if (bundleRD.hasSucceeded && isNotEmpty(bundleRD.payload)) { + return this.bitstreamService.findAllByItemAndBundleName(this.item, CC_CONSTANTS.BUNDLE_NAME); + } + return of(null); + }), + switchMap((bitstreamsRD) => { + if (bitstreamsRD && bitstreamsRD.hasSucceeded && isNotEmpty(bitstreamsRD.payload)) { + const bitstreams = bitstreamsRD.payload; + if (bitstreams.page.length > 0) { + // Look for license URL in bitstream metadata or name + const licenseBitstream = bitstreams.page.find(bitstream => + bitstream.name.includes('license') || + bitstream.metadata[CC_CONSTANTS.METADATA_FIELDS[0]]?.[0]?.value + ); + const url = licenseBitstream?.metadata[CC_CONSTANTS.METADATA_FIELDS[0]]?.[0]?.value || + metadataUrl || + ''; + return of(url); + } + } + // Fallback to cached metadata-based detection + return of(metadataUrl); + }), + catchError(() => of('')) + ); + + // Extract license name from URL or metadata + this.ccLicenseName$ = this.ccLicenseUrl$.pipe( + map((url: string) => { + if (isNotEmpty(url)) { + return this.extractLicenseNameFromUrl(url); + } + return ''; + }) + ); + } + + /** + * Extract Creative Commons license URL from item metadata + */ + private extractUrlFromMetadata(): string { + // Check for common CC license metadata fields + for (const field of CC_CONSTANTS.METADATA_FIELDS) { + const values = this.item.allMetadata(field); + if (values) { + for (const value of values) { + if (value.value && value.value.includes(CC_CONSTANTS.DOMAIN)) { + return value.value; + } + } + } + } + return ''; + } + + /** + * Extract license name from Creative Commons URL + */ + private extractLicenseNameFromUrl(url: string): string { + if (!url || !url.includes(CC_CONSTANTS.DOMAIN)) { + return ''; + } + + // Parse common CC license types from URL + for (const [pattern, name] of Object.entries(CC_CONSTANTS.LICENSE_TYPES)) { + if (url.includes(pattern)) { + // Extract version if present + const versionMatch = url.match(/(\d+\.\d+)/); + const version = versionMatch ? ` ${versionMatch[1]}` : ''; + return `${name}${version}`; + } + } + + return CC_CONSTANTS.DEFAULT_MESSAGES.DEFAULT_LICENSE; + } + + /** + * Get Creative Commons icon class based on license type + */ + getCcIconClass(licenseName: string): string { + const lowerName = licenseName.toLowerCase(); + + if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.CC0.some(pattern => lowerName.includes(pattern))) { + return CC_CONSTANTS.ICON_CLASSES.ZERO; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC_ND.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY} ${CC_CONSTANTS.ICON_CLASSES.NC} ${CC_CONSTANTS.ICON_CLASSES.ND}`; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC_SA.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY} ${CC_CONSTANTS.ICON_CLASSES.NC} ${CC_CONSTANTS.ICON_CLASSES.SA}`; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY} ${CC_CONSTANTS.ICON_CLASSES.NC}`; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_ND.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY} ${CC_CONSTANTS.ICON_CLASSES.ND}`; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_SA.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY} ${CC_CONSTANTS.ICON_CLASSES.SA}`; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY.some(pattern => lowerName.includes(pattern))) { + return `${CC_CONSTANTS.ICON_CLASSES.BASE} ${CC_CONSTANTS.ICON_CLASSES.BY}`; + } + + return CC_CONSTANTS.ICON_CLASSES.BASE; + } + + /** + * Get license type for switch case in template + */ + getLicenseType(licenseName: string): string { + if (!licenseName) return ''; + + const name = licenseName.toLowerCase(); + + if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.CC0.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.CC0; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC_ND.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY_NC_ND; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC_SA.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY_NC_SA; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_NC.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY_NC; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_ND.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY_ND; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY_SA.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY_SA; + } else if (CC_CONSTANTS.LICENSE_TYPE_PATTERNS.BY.some(pattern => name.includes(pattern))) { + return CC_CONSTANTS.TEMPLATE_SWITCH_CASES.BY; + } else { + return ''; + } + } +} diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index ddb3be855d7..410926c9530 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -1,11 +1,11 @@ import { ChangeDetectorRef, Component, Inject, OnChanges, SimpleChanges, OnInit } from '@angular/core'; -import { Observable, of as observableOf, Subscription, tap } from 'rxjs'; +import { Observable, of as observableOf, Subscription, tap, Subject } from 'rxjs'; import { Field, Option, SubmissionCcLicence } from '../../../core/submission/models/submission-cc-license.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../../core/shared/operators'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, take, debounceTime, switchMap, startWith, shareReplay } from 'rxjs/operators'; import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { renderSectionFor } from '../sections-decorator'; @@ -89,6 +89,11 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ private _isLastPage: boolean; + /** + * Subject to trigger CC license link updates with debouncing + */ + private ccLicenseLinkTrigger$ = new Subject(); + /** * The Creative Commons link saved in the workspace item. */ @@ -129,14 +134,20 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent ngOnInit(): void { super.ngOnInit(); - if (hasNoValue(this.ccLicenseLink$)) { - this.ccLicenseLink$ = this.getCcLicenseLink$(); - } + // Initialize the debounced license link observable + this.ccLicenseLink$ = this.ccLicenseLinkTrigger$.pipe( + startWith(undefined), // Start with initial trigger + debounceTime(300), // Debounce rapid clicks + switchMap(() => this.getCcLicenseLink$() || observableOf(null)), + shareReplay(1), // Cache the latest result + distinctUntilChanged() + ); } ngOnChanges(changes: SimpleChanges): void { if (hasValue(changes.sectionData) || hasValue(changes.submissionCcLicenses)) { - this.ccLicenseLink$ = this.getCcLicenseLink$(); + // Trigger the debounced license link update + this.ccLicenseLinkTrigger$.next(); } } @@ -164,7 +175,8 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent }, uri: undefined, }); - this.ccLicenseLink$ = this.getCcLicenseLink$(); + // Trigger the debounced license link update + this.ccLicenseLinkTrigger$.next(); } /** @@ -196,7 +208,8 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent }, accepted: false, }); - this.ccLicenseLink$ = this.getCcLicenseLink$(); + // Trigger the debounced license link update + this.ccLicenseLinkTrigger$.next(); } /** @@ -272,6 +285,8 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ onSectionDestroy(): void { this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + // Complete the subject to prevent memory leaks + this.ccLicenseLinkTrigger$.complete(); } /** @@ -284,18 +299,35 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent filter((sectionState) => { return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errorsToShow)); }), - distinctUntilChanged(), + distinctUntilChanged((prev, curr) => { + // More precise comparison to prevent unnecessary updates + const prevData = prev?.data as WorkspaceitemSectionCcLicenseObject; + const currData = curr?.data as WorkspaceitemSectionCcLicenseObject; + return prevData?.accepted === currData?.accepted && + prevData?.uri === currData?.uri && + JSON.stringify(prevData?.ccLicense) === JSON.stringify(currData?.ccLicense); + }), map((sectionState) => sectionState.data as WorkspaceitemSectionCcLicenseObject), ).subscribe((data) => { - if (this.data.accepted !== data.accepted) { + const wasAccepted = this.data.accepted; + const wasUri = this.data.uri; + + // Only process if acceptance state actually changed + if (wasAccepted !== data.accepted && data.accepted !== undefined) { const path = this.pathCombiner.getPath('uri'); - if (data.accepted) { - this.getCcLicenseLink$().pipe( - take(1), - ).subscribe((link) => { - this.operationsBuilder.add(path, link.toString(), false, true); - }); - } else if (!!this.data.uri) { + if (data.accepted && !wasAccepted) { + // Only add URI if we're switching from not accepted to accepted + const licenseLink$ = this.getCcLicenseLink$(); + if (licenseLink$) { + licenseLink$.pipe( + take(1), + filter(link => !!link && link !== wasUri) // Only proceed if link exists and is different + ).subscribe((link) => { + this.operationsBuilder.add(path, link.toString(), false, true); + }); + } + } else if (!data.accepted && wasAccepted && !!this.data.uri) { + // Only remove URI if we're switching from accepted to not accepted this.operationsBuilder.remove(path); } } @@ -305,12 +337,12 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent getFirstCompletedRemoteData(), getRemoteDataPayload() ).subscribe((remoteData) => { - if (remoteData === undefined || remoteData.values.length === 0) { - // No value configured, use blank value (International jurisdiction) - this.defaultJurisdiction = ''; - } else { - this.defaultJurisdiction = remoteData.values[0]; - } + if (remoteData === undefined || remoteData.values.length === 0) { + // No value configured, use blank value (International jurisdiction) + this.defaultJurisdiction = ''; + } else { + this.defaultJurisdiction = remoteData.values[0]; + } }) ); this.loadCcLicences(); diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 6e2e6d8c281..7b2e9275f8a 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -3812,6 +3812,12 @@ // "item.page.journal.search.title": "Articles in this journal", "item.page.journal.search.title": "Články v tomto časopise", + // "item.page.cc-license": "License", + "item.page.cc-license": "Licence", + + // "item.page.cc-license.description": "This work is licensed under a Creative Commons license.", + "item.page.cc-license.description": "Tato práce je licencována podle licence Creative Commons.", + // "item.page.link.full": "Show full item record", "item.page.link.full": "Zobrazit celý záznam", @@ -8513,7 +8519,7 @@ // "handle-table.table.handle": "Handle", "handle-table.table.handle": "Handle", - // "handle-table.table.internal": "Is Internal", + // "handle-table.table.internal": "Is internal", "handle-table.table.internal": "Je interní", // "handle-table.table.is-internal": "Yes", @@ -8546,6 +8552,42 @@ // "handle-table.dropdown.search-button": "Search", "handle-table.dropdown.search-button": "Hledat", + // "handle-table.search.placeholder.no-option": "Please select a search option first", + "handle-table.search.placeholder.no-option": "Nejprve vyberte možnost vyhledávání", + + // "handle-table.search.placeholder.handle": "Enter handle", + "handle-table.search.placeholder.handle": "Zadejte Handle", + + // "handle-table.search.internal.select": "Select internal option", + "handle-table.search.internal.select": "Vyberte možnost interní", + + // "handle-table.search.internal.yes": "Yes", + "handle-table.search.internal.yes": "Ano", + + // "handle-table.search.internal.no": "No", + "handle-table.search.internal.no": "Ne", + + // "handle-table.search.resource-type.select": "Select resource type", + "handle-table.search.resource-type.select": "Vyberte typ zdroje", + + // "handle-table.search.resource-type.site": "Site", + "handle-table.search.resource-type.site": "Stránka", + + // "handle-table.search.resource-type.community": "Community", + "handle-table.search.resource-type.community": "Komunita", + + // "handle-table.search.resource-type.collection": "Collection", + "handle-table.search.resource-type.collection": "Kolekce", + + // "handle-table.search.resource-type.item": "Item", + "handle-table.search.resource-type.item": "Záznam", + + // "handle-table.search.aria.select-internal": "Select internal option", + "handle-table.search.aria.select-internal": "Vyberte možnost interní", + + // "handle-table.search.aria.select-resource-type": "Select resource type", + "handle-table.search.aria.select-resource-type": "Vyberte typ zdroje", + // "handle-table.global-actions.title": "Global Actions", "handle-table.global-actions.title": "Globální akce", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4a26129445c..bd6a37ddd45 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2541,6 +2541,10 @@ "item.page.journal.search.title": "Articles in this journal", + "item.page.cc-license": "License", + + "item.page.cc-license.description": "This work is licensed under a Creative Commons license.", + "item.page.link.full": "Show full item record", "item.page.link.simple": "Show simple item record", @@ -5675,7 +5679,7 @@ "handle-table.table.handle": "Handle", - "handle-table.table.internal": "Is Internal", + "handle-table.table.internal": "Is internal", "handle-table.table.is-internal": "Yes", @@ -5697,6 +5701,30 @@ "handle-table.dropdown.search-button": "Search", + "handle-table.search.placeholder.no-option": "Please select a search option first", + + "handle-table.search.placeholder.handle": "Enter handle", + + "handle-table.search.internal.select": "Select internal option", + + "handle-table.search.internal.yes": "Yes", + + "handle-table.search.internal.no": "No", + + "handle-table.search.resource-type.select": "Select resource type", + + "handle-table.search.resource-type.site": "Site", + + "handle-table.search.resource-type.community": "Community", + + "handle-table.search.resource-type.collection": "Collection", + + "handle-table.search.resource-type.item": "Item", + + "handle-table.search.aria.select-internal": "Select internal option", + + "handle-table.search.aria.select-resource-type": "Select resource type", + "handle-table.global-actions.title": "Global Actions", "handle-table.global-actions.actions-list-message": "This is the list of available global actions.",