Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bf14771
[DURACOM-413] port custom url functionality
FrancescoMolinaro Nov 12, 2025
66bd310
Merge remote-tracking branch 'gitHub/main' into task/main/DURACOM-413
FrancescoMolinaro Nov 13, 2025
b42bf55
[DURACOM-413] additional tests
FrancescoMolinaro Nov 13, 2025
e88a40d
[DURACOM-413] update labels, fix sync script, adapt section handling
FrancescoMolinaro Nov 18, 2025
c93cb50
[DURACOM-413] update error handling/validation + resync translations
FrancescoMolinaro Nov 19, 2025
75937c4
Merge remote-tracking branch 'gitHub/main' into task/main/DURACOM-413
FrancescoMolinaro Dec 5, 2025
d1c1ecf
[DURACOM-413] fix lint
FrancescoMolinaro Dec 5, 2025
8bdfd01
[DURACOM-426] init integration of authority framework
FrancescoMolinaro Dec 10, 2025
a9dc8ec
Merge branch 'task/main/DURACOM-413' into task/main/DURACOM-426
FrancescoMolinaro Dec 12, 2025
8c073ff
[DURACOM-426] port metadata link components for authorithy
FrancescoMolinaro Dec 15, 2025
d262480
[DURACOM-426] fix some tests
FrancescoMolinaro Dec 15, 2025
9c5e6bb
[DURACOM-426] fix some unit tests
FrancescoMolinaro Dec 16, 2025
a514b6e
[DURACOM-426] fix tests and lint
FrancescoMolinaro Dec 16, 2025
a9ee4e5
[DURACOM-426] add authority link for md representation
FrancescoMolinaro Dec 16, 2025
306029c
Merge remote-tracking branch 'gitHub/main' into task/main/DURACOM-426
FrancescoMolinaro Dec 18, 2025
36c77fe
[DURACOM-426] clean up, add example, minor restyle, fix templates
FrancescoMolinaro Dec 18, 2025
e9f3df5
[DURACOM-426] improve tests
FrancescoMolinaro Dec 18, 2025
5b6fa80
[DURACOM-426] refactor
FrancescoMolinaro Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -629,3 +629,47 @@ geospatialMapViewer:
accessibility:
# The duration in days after which the accessibility settings cookie expires
cookieExpirationDuration: 7

# Configuration for custom layout
layout:
# Configuration of icons and styles to be used for each authority controlled link
authorityRef:
- entityType: DEFAULT
entityStyle:
default:
icon: fa fa-user
style: text-info
- entityType: PERSON
entityStyle:
person:
icon: fa fa-user
style: text-success
default:
icon: fa fa-user
style: text-info
- entityType: ORGUNIT
entityStyle:
default:
icon: fa fa-university
style: text-success
- entityType: PROJECT
entityStyle:
default:
icon: fas fa-project-diagram
style: text-success

# When the search results are retrieved, for each item type the metadata with a valid authority value are inspected.
# Referenced items will be fetched with a find all by id strategy to avoid individual rest requests
# to efficiently display the search results.
followAuthorityMetadata:
- type: Publication
metadata: dc.contributor.author
- type: Product
metadata: dc.contributor.author

# The maximum number of item to process when following authority metadata values.
followAuthorityMaxItemLimit: 100

# The maximum number of metadata values to process for each metadata key
# when following authority metadata values.
followAuthorityMetadataValuesLimit: 5
11 changes: 4 additions & 7 deletions scripts/sync-i18n-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,11 @@ function parseCliInput() {
.option('-o, --output-file <output>', 'where output of script ends up; mutually exclusive with -i')
.usage('([-d <output-dir>] [-s <source-file>]) || (-t <target-file> (-i | -o <output>) [-s <source-file>])')
.parse(process.argv);

const sourceFile = program.opts().sourceFile;

if (!program.targetFile) {
if (!program.targetFile) {
fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => {
if (!sourceFile.toString().endsWith(file)) {
if (program.opts().sourceFile && !program.opts().sourceFile.toString().endsWith(file)) {
const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file);
console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + sourceFile);
console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.opts().sourceFile);
if (program.outputDir) {
if (!fs.existsSync(program.outputDir)) {
fs.mkdirSync(program.outputDir);
Expand All @@ -69,7 +66,7 @@ function parseCliInput() {
console.log(program.outputHelp());
process.exit(1);
}
if (!checkIfFileExists(sourceFile)) {
if (!checkIfFileExists(program.opts().sourceFile)) {
console.error('Path of source file is not valid.');
console.log(program.outputHelp());
process.exit(1);
Expand Down
91 changes: 91 additions & 0 deletions src/app/core/data/item-data.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
import { Store } from '@ngrx/store';
import {
cold,
Expand All @@ -13,6 +14,7 @@ import { RestResponse } from '../cache/response.models';
import { CoreState } from '../core-state.model';
import { NotificationsService } from '../notification-system/notifications.service';
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { Item } from '../shared/item.model';
import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../testing/remote-data-build.service.mock';
import { getMockRequestService } from '../testing/request.service.mock';
Expand Down Expand Up @@ -209,4 +211,93 @@ describe('ItemDataService', () => {
});
});

describe('findByCustomUrl', () => {
let itemDataService: ItemDataService;
let searchData: any;
let findByHrefSpy: jasmine.Spy;
let getSearchByHrefSpy: jasmine.Spy;
const id = 'custom-id';
const fakeHrefObs = of('https://rest.api/core/items/search/findByCustomURL?q=custom-id');
const linksToFollow = [];
const projections = ['full', 'detailed'];

beforeEach(() => {
searchData = jasmine.createSpyObj('searchData', ['getSearchByHref']);
getSearchByHrefSpy = searchData.getSearchByHref.and.returnValue(fakeHrefObs);
itemDataService = new ItemDataService(
requestService,
rdbService,
objectCache,
halEndpointService,
notificationsService,
comparator,
browseService,
bundleService,
);

(itemDataService as any).searchData = searchData;
findByHrefSpy = spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
});

it('should call searchData.getSearchByHref with correct parameters', () => {
itemDataService.findByCustomUrl(id, true, true, linksToFollow, projections).subscribe();

expect(getSearchByHrefSpy).toHaveBeenCalledWith(
'findByCustomURL',
jasmine.objectContaining({
searchParams: jasmine.arrayContaining([
jasmine.objectContaining({ fieldName: 'q', fieldValue: id }),
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'full' }),
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'detailed' }),
]),
}),
...linksToFollow,
);
});

it('should call findByHref with the href observable returned from getSearchByHref', () => {
itemDataService.findByCustomUrl(id, true, false, linksToFollow, projections).subscribe();

expect(findByHrefSpy).toHaveBeenCalledWith(fakeHrefObs, true, false, ...linksToFollow);
});
});

describe('findById', () => {
let itemDataService: ItemDataService;

beforeEach(() => {
itemDataService = new ItemDataService(
requestService,
rdbService,
objectCache,
halEndpointService,
notificationsService,
comparator,
browseService,
bundleService,
);
spyOn(itemDataService, 'findByCustomUrl').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
spyOn(itemDataService as any, 'getIDHrefObs').and.returnValue(of('uuid-href'));
});

it('should call findByHref when given a valid UUID', () => {
const validUuid = '4af28e99-6a9c-4036-a199-e1b587046d39';
itemDataService.findById(validUuid).subscribe();

expect((itemDataService as any).getIDHrefObs).toHaveBeenCalledWith(encodeURIComponent(validUuid));
expect(itemDataService.findByHref).toHaveBeenCalled();
expect(itemDataService.findByCustomUrl).not.toHaveBeenCalled();
});

it('should call findByCustomUrl when given a non-UUID id', () => {
const nonUuid = 'custom-url';
itemDataService.findById(nonUuid).subscribe();

expect(itemDataService.findByCustomUrl).toHaveBeenCalledWith(nonUuid, true, true, []);
expect(itemDataService.findByHref).not.toHaveBeenCalled();
});
});


});
56 changes: 56 additions & 0 deletions src/app/core/data/item-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
switchMap,
take,
} from 'rxjs/operators';
import { validate as uuidValidate } from 'uuid';

import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
Expand All @@ -34,6 +35,7 @@ import { NotificationsService } from '../notification-system/notifications.servi
import { Bundle } from '../shared/bundle.model';
import { Collection } from '../shared/collection.model';
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { FollowLinkConfig } from '../shared/follow-link-config.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
Expand All @@ -58,6 +60,7 @@ import {
PatchData,
PatchDataImpl,
} from './base/patch-data';
import { SearchDataImpl } from './base/search-data';
import { BundleDataService } from './bundle-data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { FindListOptions } from './find-list-options.model';
Expand All @@ -83,6 +86,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
private createData: CreateData<Item>;
private patchData: PatchData<Item>;
private deleteData: DeleteData<Item>;
private searchData: SearchDataImpl<Item>;

protected constructor(
protected linkPath,
Expand All @@ -101,6 +105,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.patchData = new PatchDataImpl<Item>(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}

/**
Expand Down Expand Up @@ -425,6 +430,57 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
return this.createData.create(object, ...params);
}

/**
* Returns an observable of {@link RemoteData} of an object, based on its CustomURL or ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id CustomUrl or UUID of object we want to retrieve
* @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
* @param projections List of {@link projections} used to pass as parameters
*/
public findByCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, linksToFollow: FollowLinkConfig<Item>[], projections: string[] = []): Observable<RemoteData<Item>> {
const searchHref = 'findByCustomURL';

const options = Object.assign({}, {
searchParams: [
new RequestParam('q', id),
],
});

projections.forEach((projection) => {
options.searchParams.push(new RequestParam('projection', projection));
});

const hrefObs = this.searchData.getSearchByHref(searchHref, options, ...linksToFollow);

return this.findByHref(hrefObs, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @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 findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {

if (uuidValidate(id)) {
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} else {
return this.findByCustomUrl(id, useCachedVersionIfAvailable, reRequestOnStale, linksToFollow);
}
}

}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/provide-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
makeEnvironmentProviders,
} from '@angular/core';
import { APP_CONFIG } from '@dspace/config/app-config.interface';
import { SubmissionCustomUrl } from '@dspace/core/submission/models/submission-custom-url.model';

import { AuthStatus } from './auth/models/auth-status.model';
import { ShortLivedToken } from './auth/models/short-lived-token.model';
Expand Down Expand Up @@ -228,4 +229,5 @@ export const models =
StatisticsEndpoint,
CorrectionType,
SupervisionOrder,
SubmissionCustomUrl,
];
9 changes: 8 additions & 1 deletion src/app/core/router/utils/dso-route.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ export function getCommunityPageRoute(communityId: string) {
*/
export function getItemPageRoute(item: Item) {
const type = item.firstMetadataValue('dspace.entity.type');
return getEntityPageRoute(type, item.uuid);
let url = item.uuid;

if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl')) {
url = item.firstMetadataValue('dspace.customurl');
}

return getEntityPageRoute(type, url);
}


export function getEntityPageRoute(entityType: string, itemId: string) {
if (isNotEmpty(entityType)) {
return new URLCombiner(`/${ENTITY_MODULE_PATH}`, encodeURIComponent(entityType.toLowerCase()), itemId).toString();
Expand Down
14 changes: 14 additions & 0 deletions src/app/core/shared/dspace-object.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
return Metadata.all(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML);
}


/**
* Gets all matching metadata in this DSpaceObject, up to a limit.
*
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
* @param {number} limit The maximum number of results to return.
* @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done.
* @returns {MetadataValue[]} the matching values or an empty array.
*/
limitedMetadata(keyOrKeys: string | string[], limit: number, valueFilter?: MetadataValueFilter): MetadataValue[] {
return Metadata.all(this.metadata, keyOrKeys, null, valueFilter, false, limit);
}


/**
* Like [[allMetadata]], but only returns string values.
*
Expand Down
7 changes: 7 additions & 0 deletions src/app/core/shared/item.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
@autoserializeAs(Boolean, 'withdrawn')
isWithdrawn: boolean;

/**
* A boolean representing if this Item is currently withdrawn or not
*/
@autoserializeAs(String, 'entityType')
entityType: string;


/**
* The {@link HALLink}s for this Item
*/
Expand Down
24 changes: 24 additions & 0 deletions src/app/core/shared/metadata.models.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/* eslint-disable max-classes-per-file */
import { hasValue } from '@dspace/shared/utils/empty.util';
import {
autoserialize,
Deserialize,
Serialize,
} from 'cerialize';
import { v4 as uuidv4 } from 'uuid';


export const VIRTUAL_METADATA_PREFIX = 'virtual::';

/** A single metadata value and its properties. */
Expand Down Expand Up @@ -56,6 +58,24 @@ export class MetadataValue implements MetadataValueInterface {
@autoserialize
confidence: number;

/**
* Returns true if this Metadatum's authority key starts with 'virtual::'
*/
get isVirtual(): boolean {
return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX);
}

/**
* If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'.
* Returns undefined otherwise.
*/
get virtualValue(): string {
if (this.isVirtual) {
return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length);
} else {
return undefined;
}
}
}

/** Constraints for matching metadata values. */
Expand All @@ -74,6 +94,10 @@ export interface MetadataValueFilter {

/** Whether the value constraint should match as a substring. */
substring?: boolean;
/**
* Whether to negate the filter
*/
negate?: boolean;
}

export class MetadatumViewModel {
Expand Down
Loading
Loading