From 5f432f831900665548eb76629afa694cc0a47526 Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Tue, 19 May 2026 17:03:43 -0400 Subject: [PATCH 1/3] 140019: Update RSS component to support other formats currently just atom and rss --- ...top-level-community-list.component.spec.ts | 2 + src/app/shared/rss-feed/rss.component.spec.ts | 337 +++++++++++++----- src/app/shared/rss-feed/rss.component.ts | 166 ++++++--- 3 files changed, 377 insertions(+), 128 deletions(-) diff --git a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts index 2ad89f6ffcb..b76f0a0850d 100644 --- a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts +++ b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts @@ -27,6 +27,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { environment } from '../../../environments/environment.test'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { HostWindowService } from '../../shared/host-window.service'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; import { SearchConfigurationService } from '../../shared/search/search-configuration.service'; @@ -161,6 +162,7 @@ describe('TopLevelCommunityListComponent', () => { { provide: LinkHeadService, useValue: linkHeadService }, { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, + { provide: DSpaceObjectDataService, useValue: {} }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/src/app/shared/rss-feed/rss.component.spec.ts b/src/app/shared/rss-feed/rss.component.spec.ts index 7dd404d06eb..eb2898db64f 100644 --- a/src/app/shared/rss-feed/rss.component.spec.ts +++ b/src/app/shared/rss-feed/rss.component.spec.ts @@ -12,14 +12,11 @@ import { SortOptions, } from '@dspace/core/cache/models/sort-options.model'; import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; -import { RemoteData } from '@dspace/core/data/remote-data'; import { GroupDataService } from '@dspace/core/eperson/group-data.service'; import { PaginationService } from '@dspace/core/pagination/pagination.service'; -import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; import { LinkHeadService } from '@dspace/core/services/link-head.service'; import { Collection } from '@dspace/core/shared/collection.model'; import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; -import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { SearchFilter } from '@dspace/core/shared/search/models/search-filter.model'; import { MockActivatedRoute } from '@dspace/core/testing/active-router.mock'; import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; @@ -27,13 +24,11 @@ import { RouterMock } from '@dspace/core/testing/router.mock'; import { SearchConfigurationServiceStub } from '@dspace/core/testing/search-configuration-service.stub'; import { getMockTranslateService } from '@dspace/core/testing/translate.service.mock'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; -import { - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$, -} from '@dspace/core/utilities/remote-data.utils'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { Community } from '../../core/shared/community.model'; import { SearchConfigurationService } from '../search/search-configuration.service'; import { RSSComponent } from './rss.component'; @@ -43,58 +38,97 @@ describe('RssComponent', () => { let fixture: ComponentFixture; let uuid: string; let query: string; - let groupDataService: GroupDataService; - let linkHeadService: LinkHeadService; - let configurationDataService: ConfigurationDataService; + let configurationDataService: jasmine.SpyObj; + let groupDataService: jasmine.SpyObj; + let dspaceObjectService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; let paginationService; - beforeEach(waitForAsync(() => { - const mockCollection: Collection = Object.assign(new Collection(), { - id: 'ce41d451-97ed-4a9c-94a1-7de34f16a9f4', - name: 'test-collection', - _links: { - mappedItems: { - href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4/mappedItems', - }, - self: { - href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4', - }, + const mockCollection: Collection = Object.assign(new Collection(), { + id: 'ce41d451-97ed-4a9c-94a1-7de34f16a9f4', + name: 'test-collection', + _links: { + mappedItems: { + href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4/mappedItems', }, + self: { + href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4', + }, + }, + }); + + const mockCommunity: Community = Object.assign(new Community(), { + id: 'da9a4b37-3e8e-402e-9b14-7c5b8a1d4f21', + name: 'test-community', + _links: { + self: { + href: 'https://rest.api/communities/da9a4b37-3e8e-402e-9b14-7c5b8a1d4f21', + }, + }, + }); + + /** + * Reconfigure the configurationDataService spy and reinitialise the component. + * @param formats The raw format values to expose via websvc.opensearch.formats + * @param dso Either the collection or community that we are acting like the rss page is loaded on + */ + function setupComponent(formats: string[], scopedDso?: Collection | Community): void { + configurationDataService = TestBed.inject(ConfigurationDataService) as jasmine.SpyObj; + (configurationDataService.findByPropertyName as jasmine.Spy).and.callFake((property: string) => { + switch (property) { + case 'websvc.opensearch.enable': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'websvc.opensearch.enable', + values: ['true'], + })); + case 'websvc.opensearch.formats': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'websvc.opensearch.formats', + values: formats, + })); + case 'websvc.opensearch.svccontext': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'websvc.opensearch.svccontext', + values: ['opensearch/search'], + })); + default: + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: property, + values: [], + })); + } }); - configurationDataService = jasmine.createSpyObj('configurationDataService', { - findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { - name: 'test', - values: [ - 'org.dspace.ctask.general.ProfileFormats = test', - ], - })), - }); - linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '', - }); - const mockCollectionRD: RemoteData = createSuccessfulRemoteDataObject(mockCollection); - const mockSearchOptions = of(new PaginatedSearchOptions({ - pagination: Object.assign(new PaginationComponentOptions(), { - id: 'search-page-configuration', - pageSize: 10, - currentPage: 1, - }), - sort: new SortOptions('dc.title', SortDirection.ASC), - })); - groupDataService = jasmine.createSpyObj('groupsDataService', { - findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), - getGroupRegistryRouterLink: '', - getUUIDFromString: '', - }); + + groupDataService = TestBed.inject(GroupDataService) as jasmine.SpyObj; + groupDataService.findListByHref.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList([])), + ); + groupDataService.getGroupRegistryRouterLink.and.returnValue(''); + + linkHeadService = TestBed.inject(LinkHeadService) as jasmine.SpyObj; + linkHeadService.addTag.calls.reset(); + linkHeadService.removeTag.calls.reset(); + + dspaceObjectService = TestBed.inject(DSpaceObjectDataService) as jasmine.SpyObj; + if (scopedDso) { + dspaceObjectService.findById.and.returnValue(createSuccessfulRemoteDataObject$(scopedDso)); + groupDataService.getUUIDFromString.and.returnValue(scopedDso.id); + } + + fixture = TestBed.createComponent(RSSComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + } + + beforeEach(waitForAsync(() => { paginationService = new PaginationServiceStub(); - const searchConfigService = { - paginatedSearchOptions: mockSearchOptions, - }; + TestBed.configureTestingModule({ providers: [ - { provide: GroupDataService, useValue: groupDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: ConfigurationDataService, useValue: jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']) }, + { provide: GroupDataService, useValue: jasmine.createSpyObj('GroupDataService', ['findListByHref', 'getGroupRegistryRouterLink', 'getUUIDFromString']) }, + { provide: DSpaceObjectDataService, useValue: jasmine.createSpyObj('DSpaceObjectDataService', ['findById']) }, + { provide: LinkHeadService, useValue: jasmine.createSpyObj('LinkHeadService', ['addTag', 'removeTag']) }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, { provide: PaginationService, useValue: paginationService }, { provide: Router, useValue: new RouterMock() }, @@ -109,42 +143,185 @@ describe('RssComponent', () => { options = new SortOptions('dc.title', SortDirection.DESC); uuid = '2cfcf65e-0a51-4bcb-8592-b8db7b064790'; query = 'test'; - fixture = TestBed.createComponent(RSSComponent); - comp = fixture.componentInstance; }); - it('should formulate the correct url given params in url', () => { - const route = comp.formulateRoute(uuid, 'opensearch/search', options, query); - expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test'); - }); + describe('formulateRoute', () => { + beforeEach(() => { + fixture = TestBed.createComponent(RSSComponent); + comp = fixture.componentInstance; + }); - it('should skip uuid if its null', () => { - const route = comp.formulateRoute(null, 'opensearch/search', options, query); - expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=test'); - }); + it('should formulate the correct url given params in url', () => { + const route = comp.formulateRoute(uuid, 'opensearch/search', 'atom', options, query); + expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test'); + }); + + it('should skip uuid if its null', () => { + const route = comp.formulateRoute(null, 'opensearch/search', 'atom', options, query); + expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=test'); + }); - it('should default to query * if none provided', () => { - const route = comp.formulateRoute(null, 'opensearch/search', options, null); - expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=*'); + it('should default to query * if none provided', () => { + const route = comp.formulateRoute(null, 'opensearch/search', 'atom', options, null); + expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=*'); + }); + + it('should include filters in opensearch url if provided', () => { + const filters = [ + new SearchFilter('f.test', ['value', 'another value'], 'contains'), + new SearchFilter('f.range', ['[1987 TO 1988]'], 'equals'), + ]; + const route = comp.formulateRoute(uuid, 'opensearch/search', 'atom', options, query, filters); + expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&f.test=value,contains&f.test=another%20value,contains&f.range=%5B1987%20TO%201988%5D,equals'); + }); + + it('should include configuration in opensearch url if provided', () => { + const route = comp.formulateRoute(uuid, 'opensearch/search', 'atom', options, query, null, 'adminConfiguration'); + expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&configuration=adminConfiguration'); + }); + + it('should include rpp in opensearch url if provided', () => { + const route = comp.formulateRoute(uuid, 'opensearch/search', 'atom', options, query, null, null, 50); + expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&rpp=50'); + }); }); - it('should include filters in opensearch url if provided', () => { - const filters = [ - new SearchFilter('f.test', ['value','another value'], 'contains'), // should be split into two arguments, spaces should be URI-encoded - new SearchFilter('f.range', ['[1987 TO 1988]'], 'equals'), // value should be URI-encoded, ',equals' should not - ]; - const route = comp.formulateRoute(uuid, 'opensearch/search', options, query, filters); - expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&f.test=value,contains&f.test=another%20value,contains&f.range=%5B1987%20TO%201988%5D,equals'); + describe('when formats are configured as html,atom,rss', () => { + beforeEach(() => { + setupComponent(['html', 'atom', 'rss']); + }); + + it('should set formats$ to only the recognised formats, dropping html', () => { + expect(comp.formats$.getValue()).toEqual(['atom', 'rss']); + }); + + it('should set route$ to the atom feed (first recognised format)', () => { + expect(comp.route$.getValue()).toContain('format=atom'); + }); + + it('should add a rel="search" link pointing to the atom feed', () => { + const searchTag = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .find(tag => tag.rel === 'search'); + expect(searchTag).toBeTruthy(); + expect(searchTag.type).toBe('application/atom+xml'); + }); + + it('should add a rel="alternate" link for atom', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + expect(alternateTags.some(t => t.type === 'application/atom+xml')).toBeTrue(); + }); + + it('should add a rel="alternate" link for rss', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + expect(alternateTags.some(t => t.type === 'application/rss+xml')).toBeTrue(); + }); }); - it('should include configuration in opensearch url if provided', () => { - const route = comp.formulateRoute(uuid, 'opensearch/search', options, query, null, 'adminConfiguration'); - expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&configuration=adminConfiguration'); + describe('when formats are configured as html,rss', () => { + beforeEach(() => { + setupComponent(['html', 'rss']); + }); + + it('should set formats$ to only the recognised formats, dropping html', () => { + expect(comp.formats$.getValue()).toEqual(['rss']); + }); + + it('should set route$ to the rss feed (first recognised format)', () => { + expect(comp.route$.getValue()).toContain('format=rss'); + }); + + it('should add a rel="search" link pointing to the rss feed', () => { + const searchTag = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .find(tag => tag.rel === 'search'); + expect(searchTag).toBeTruthy(); + expect(searchTag.type).toBe('application/rss+xml'); + }); + + it('should add a rel="alternate" link only for rss', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + expect(alternateTags.length).toBe(1); + expect(alternateTags[0].type).toBe('application/rss+xml'); + }); + + it('should not add a rel="alternate" link for atom', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + expect(alternateTags.some(t => t.type === 'application/atom+xml')).toBeFalse(); + }); }); - it('should include rpp in opensearch url if provided', () => { - const route = comp.formulateRoute(uuid, 'opensearch/search', options, query, null, null, 50); - expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test&rpp=50'); + describe('when scoped to a collection', () => { + beforeEach(() => { + setupComponent(['html', 'atom', 'rss'], mockCollection); + }); + + it('should include "Collection" in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).toContain('Collection'); + } + }); + + it('should include the collection name in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).toContain(mockCollection.name); + } + }); + + it('should not include "Community" in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).not.toContain('Community'); + } + }); + + describe('when scoped to a community', () => { + beforeEach(() => { + setupComponent(['html', 'atom', 'rss'], mockCommunity); + }); + + it('should include "Community" in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).toContain('Community'); + } + }); + + it('should include the community name in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).toContain(mockCommunity.name); + } + }); + + it('should not include "Collection" in the rel="alternate" link titles', () => { + const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() + .map(c => c.args[0]) + .filter(tag => tag.rel === 'alternate'); + for (const tag of alternateTags) { + expect(tag.title).not.toContain('Collection'); + } + }); + }); }); }); - diff --git a/src/app/shared/rss-feed/rss.component.ts b/src/app/shared/rss-feed/rss.component.ts index b209690d195..9a09115a32a 100644 --- a/src/app/shared/rss-feed/rss.component.ts +++ b/src/app/shared/rss-feed/rss.component.ts @@ -33,13 +33,25 @@ import { import { BehaviorSubject, filter, + forkJoin, Subscription, } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { Collection } from '../../core/shared/collection.model'; +import { Community } from '../../core/shared/community.model'; import { SearchConfigurationService } from '../search/search-configuration.service'; +/** + * Mapping of supported OpenSearch feed formats to their MIME types. + */ +const OPENSEARCH_FORMAT_MIME_TYPES: Record = { + atom: 'application/atom+xml', + rss: 'application/rss+xml', +}; /** * The Rss feed button component. */ @@ -60,6 +72,8 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { route$: BehaviorSubject = new BehaviorSubject(''); isEnabled$: BehaviorSubject = new BehaviorSubject(null); isActivated$: BehaviorSubject = new BehaviorSubject(false); + formats$: BehaviorSubject = new BehaviorSubject([]); + @Input() sortConfig?: SortOptions; uuid: string; @@ -72,12 +86,15 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { private searchConfigurationService: SearchConfigurationService, private router: Router, private route: ActivatedRoute, + private dsoNameService: DSONameService, + private dspaceObjectService: DSpaceObjectDataService, protected paginationService: PaginationService, protected translateService: TranslateService) { } ngOnDestroy(): void { this.linkHeadService.removeTag("rel='alternate'"); + this.linkHeadService.removeTag("rel='search'"); this.subs.forEach(sub => { sub.unsubscribe(); }); @@ -94,20 +111,41 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { // Get initial UUID from URL this.uuid = this.groupDataService.getUUIDFromString(this.router.url); - // Check if RSS is enabled - this.subs.push(this.configurationService.findByPropertyName('websvc.opensearch.enable').pipe( - getFirstCompletedRemoteData(), - ).subscribe((result) => { - if (result.hasSucceeded) { - const enabled = (result.payload.values[0] === 'true'); - this.isEnabled$.next(enabled); - - // If enabled, get the OpenSearch URI - if (enabled) { - this.getOpenSearchUri(); + // Check if RSS is enabled, and if so fetch the supported formats in one + // combined subscription so that both values are always consistent with + // each other. + this.subs.push( + forkJoin([ + this.configurationService.findByPropertyName('websvc.opensearch.enable').pipe( + getFirstCompletedRemoteData(), + ), + this.configurationService.findByPropertyName('websvc.opensearch.formats').pipe( + getFirstCompletedRemoteData(), + ), + ]).subscribe(([enableResult, formatsResult]) => { + if (!enableResult.hasSucceeded || enableResult.payload.values[0] !== 'true') { + this.isEnabled$.next(false); + return; + } + + const rawFormats: string[] = formatsResult.hasSucceeded ? formatsResult.payload.values : []; + + const knownFormats = rawFormats.filter(f => f in OPENSEARCH_FORMAT_MIME_TYPES); + + if (knownFormats.length === 0) { + console.warn( + 'RSSComponent: websvc.opensearch.formats contains no recognised formats ' + + `(received: [${rawFormats.join(', ')}]). Disabling RSS feed.`, + ); + this.isEnabled$.next(false); + return; } - } - })); + + this.formats$.next(knownFormats); + this.isEnabled$.next(true); + this.getOpenSearchUri(); + }), + ); // Listen for navigation events to update the UUID this.subs.push(this.router.events.pipe( @@ -145,27 +183,28 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { } /** - * Update RSS links based on current search configuration and sortConfig input + * Update RSS links based on current search configuration and sortConfig input. */ private updateRssLinks(): void { if (!this.openSearchUri || !this.isEnabled$.getValue()) { return; } - // Remove existing link tags before adding new ones - this.linkHeadService.removeTag("rel='alternate'"); + const formats = this.formats$.getValue(); + if (formats.length === 0) { + return; + } - // Get the current search options and apply our sortConfig if provided const searchOptions = this.searchConfigurationService.paginatedSearchOptions.value; const modifiedOptions = { ...searchOptions }; if (hasValue(this.sortConfig)) { modifiedOptions.sort = this.sortConfig; } - // Create the RSS feed URL - const route = environment.rest.baseUrl + this.formulateRoute( + this.addLinks( this.uuid, this.openSearchUri, + formats, modifiedOptions.sort, modifiedOptions.query, modifiedOptions.filters, @@ -174,26 +213,16 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { modifiedOptions.fixedFilter, ); - // Add the link tags - this.addLinks(route); - - // Add the OpenSearch service link - this.linkHeadService.addTag({ - href: environment.rest.baseUrl + '/' + this.openSearchUri + '/service', - type: 'application/atom+xml', - rel: 'search', - title: 'Dspace', - }); - - // Update the route subject - this.route$.next(route); + this.route$.next( + environment.rest.baseUrl + this.formulateRoute(this.uuid, this.openSearchUri, formats[0], modifiedOptions.sort, modifiedOptions.query, modifiedOptions.filters, modifiedOptions.configuration, modifiedOptions.pagination?.pageSize, modifiedOptions.fixedFilter), + ); } /** - * Create a route given the different params available to opensearch + * Create a route given the different params available to opensearch. */ - formulateRoute(uuid: string, opensearch: string, sort?: SortOptions, query?: string, searchFilters?: SearchFilter[], configuration?: string, pageSize?: number, fixedFilter?: string): string { - let route = 'format=atom'; + formulateRoute(uuid: string, opensearch: string, format: string, sort?: SortOptions, query?: string, searchFilters?: SearchFilter[], configuration?: string, pageSize?: number, fixedFilter?: string): string { + let route = `format=${format}`; if (uuid) { route += `&scope=${uuid}`; } @@ -226,21 +255,62 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { } /** - * Creates tags in the header of the page + * Creates the valid link in */ - addLinks(route: string): void { - this.linkHeadService.addTag({ - href: route, - type: 'application/atom+xml', - rel: 'alternate', - title: 'Sitewide Atom feed', - }); - route = route.replace('format=atom', 'format=rss'); + addLinks(uuid: string, opensearch: string, formats: string[], sort?: SortOptions, query?: string, searchFilters?: SearchFilter[], configuration?: string, pageSize?: number, fixedFilter?: string): void { + this.linkHeadService.removeTag("rel='alternate'"); + this.linkHeadService.removeTag("rel='search'"); + + // Resolve feed title label and add rel='alternate' tags + if (uuid) { + this.dspaceObjectService.findById(uuid).pipe( + getFirstCompletedRemoteData(), + ).subscribe((result) => { + let scopeLabel: string; + let objectType = ''; + if (result.hasSucceeded) { + scopeLabel = this.dsoNameService.getName(result.payload); + if (result.payload instanceof Collection) { + objectType = 'Collection'; + } else if (result.payload instanceof Community) { + objectType = 'Community'; + // If its not a collection or community idk how we got here skip this. + } else { + return; + } + } else { + scopeLabel = 'Sitewide'; + } + this.linkHeadService.removeTag("rel='alternate'"); + for (const format of formats) { + const href = environment.rest.baseUrl + this.formulateRoute(uuid, opensearch, format, sort, query, searchFilters, configuration, pageSize, fixedFilter); + this.linkHeadService.addTag({ + href, + type: OPENSEARCH_FORMAT_MIME_TYPES[format], + rel: 'alternate', + title: `${objectType} ${scopeLabel} ${format.charAt(0).toUpperCase() + format.slice(1)} Feed`.trim(), + }); + } + }); + } else { + const scopeLabel = this.router.url.includes('/search') ? 'Search results' : 'Sitewide'; + for (const format of formats) { + const href = environment.rest.baseUrl + this.formulateRoute(uuid, opensearch, format, sort, query, searchFilters, configuration, pageSize, fixedFilter); + this.linkHeadService.addTag({ + href, + type: OPENSEARCH_FORMAT_MIME_TYPES[format], + rel: 'alternate', + title: `${scopeLabel} ${format.charAt(0).toUpperCase() + format.slice(1)} Feed`, + }); + } + } + + // Service discovery link uses the primary (first) format this.linkHeadService.addTag({ - href: route, - type: 'application/rss+xml', - rel: 'alternate', - title: 'Sitewide RSS feed', + href: environment.rest.baseUrl + '/' + opensearch + '/service', + type: OPENSEARCH_FORMAT_MIME_TYPES[formats[0]], + rel: 'search', + title: 'DSpace OpenSearch', }); } } From 2638c4c0b3b81aae80b75746dae186016cc89a43 Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Mon, 8 Jun 2026 12:04:47 -0400 Subject: [PATCH 2/3] 140019: Remove entity type prefix, fix issue with opensearch service link having search before service --- src/app/shared/rss-feed/rss.component.spec.ts | 36 ------------------- src/app/shared/rss-feed/rss.component.ts | 13 ++----- 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/src/app/shared/rss-feed/rss.component.spec.ts b/src/app/shared/rss-feed/rss.component.spec.ts index eb2898db64f..22db6076ad1 100644 --- a/src/app/shared/rss-feed/rss.component.spec.ts +++ b/src/app/shared/rss-feed/rss.component.spec.ts @@ -264,15 +264,6 @@ describe('RssComponent', () => { setupComponent(['html', 'atom', 'rss'], mockCollection); }); - it('should include "Collection" in the rel="alternate" link titles', () => { - const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() - .map(c => c.args[0]) - .filter(tag => tag.rel === 'alternate'); - for (const tag of alternateTags) { - expect(tag.title).toContain('Collection'); - } - }); - it('should include the collection name in the rel="alternate" link titles', () => { const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() .map(c => c.args[0]) @@ -282,29 +273,11 @@ describe('RssComponent', () => { } }); - it('should not include "Community" in the rel="alternate" link titles', () => { - const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() - .map(c => c.args[0]) - .filter(tag => tag.rel === 'alternate'); - for (const tag of alternateTags) { - expect(tag.title).not.toContain('Community'); - } - }); - describe('when scoped to a community', () => { beforeEach(() => { setupComponent(['html', 'atom', 'rss'], mockCommunity); }); - it('should include "Community" in the rel="alternate" link titles', () => { - const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() - .map(c => c.args[0]) - .filter(tag => tag.rel === 'alternate'); - for (const tag of alternateTags) { - expect(tag.title).toContain('Community'); - } - }); - it('should include the community name in the rel="alternate" link titles', () => { const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() .map(c => c.args[0]) @@ -313,15 +286,6 @@ describe('RssComponent', () => { expect(tag.title).toContain(mockCommunity.name); } }); - - it('should not include "Collection" in the rel="alternate" link titles', () => { - const alternateTags = (linkHeadService.addTag as jasmine.Spy).calls.all() - .map(c => c.args[0]) - .filter(tag => tag.rel === 'alternate'); - for (const tag of alternateTags) { - expect(tag.title).not.toContain('Collection'); - } - }); }); }); }); diff --git a/src/app/shared/rss-feed/rss.component.ts b/src/app/shared/rss-feed/rss.component.ts index 9a09115a32a..d3259b23d2d 100644 --- a/src/app/shared/rss-feed/rss.component.ts +++ b/src/app/shared/rss-feed/rss.component.ts @@ -267,17 +267,8 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { getFirstCompletedRemoteData(), ).subscribe((result) => { let scopeLabel: string; - let objectType = ''; if (result.hasSucceeded) { scopeLabel = this.dsoNameService.getName(result.payload); - if (result.payload instanceof Collection) { - objectType = 'Collection'; - } else if (result.payload instanceof Community) { - objectType = 'Community'; - // If its not a collection or community idk how we got here skip this. - } else { - return; - } } else { scopeLabel = 'Sitewide'; } @@ -288,7 +279,7 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { href, type: OPENSEARCH_FORMAT_MIME_TYPES[format], rel: 'alternate', - title: `${objectType} ${scopeLabel} ${format.charAt(0).toUpperCase() + format.slice(1)} Feed`.trim(), + title: `${scopeLabel} ${format.charAt(0).toUpperCase() + format.slice(1)} Feed`.trim(), }); } }); @@ -307,7 +298,7 @@ export class RSSComponent implements OnInit, OnDestroy, OnChanges { // Service discovery link uses the primary (first) format this.linkHeadService.addTag({ - href: environment.rest.baseUrl + '/' + opensearch + '/service', + href: environment.rest.baseUrl + '/' + opensearch.split('/search')[0] || '' + '/service', type: OPENSEARCH_FORMAT_MIME_TYPES[formats[0]], rel: 'search', title: 'DSpace OpenSearch', From b052b073bbebd290cbb335965569376a6220c10d Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Mon, 8 Jun 2026 12:13:53 -0400 Subject: [PATCH 3/3] 140019: Lint fix --- src/app/shared/rss-feed/rss.component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/shared/rss-feed/rss.component.ts b/src/app/shared/rss-feed/rss.component.ts index d3259b23d2d..90f71fa8dc0 100644 --- a/src/app/shared/rss-feed/rss.component.ts +++ b/src/app/shared/rss-feed/rss.component.ts @@ -41,8 +41,6 @@ import { map } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; import { SearchConfigurationService } from '../search/search-configuration.service'; /**