From 102fc26f4b7c3bbac49f4d75abdb87bfe5c64826 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 10 Sep 2025 11:19:41 -0400 Subject: [PATCH 1/5] feat(metadata): add metadata-records service --- src/app/shared/enums/index.ts | 1 + .../enums/metadata-record-format.enum.ts | 8 +++++++ src/app/shared/services/index.ts | 1 + .../services/metadata-records.service.ts | 23 +++++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 src/app/shared/enums/metadata-record-format.enum.ts create mode 100644 src/app/shared/services/metadata-records.service.ts diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 7fa1b47a4..737c71894 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -14,6 +14,7 @@ export * from './field-type.enum'; export * from './file-menu-type.enum'; export * from './filter-type.enum'; export * from './get-resources-request-type.enum'; +export * from './metadata-record-format.enum'; export * from './metadata-resource.enum'; export * from './mode.enum'; export * from './moderation-decision-form-controls.enum'; diff --git a/src/app/shared/enums/metadata-record-format.enum.ts b/src/app/shared/enums/metadata-record-format.enum.ts new file mode 100644 index 000000000..08ce831ab --- /dev/null +++ b/src/app/shared/enums/metadata-record-format.enum.ts @@ -0,0 +1,8 @@ +// metadata formats available from osf backend -- see METADATA_SERIALIZER_REGISTRY: +// https://github.com/CenterForOpenScience/osf.io/blob/develop/osf/metadata/serializers/__init__.py +export enum MetadataRecordFormat { + Turtle = 'turtle', + DataciteJson = 'datacite-json', + DataciteXml = 'datacite-xml', + SchemaDotOrgDataset = 'google-dataset-json-ld', +} diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 66362a18d..6a46dd119 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -13,6 +13,7 @@ export { JsonApiService } from './json-api.service'; export { LicensesService } from './licenses.service'; export { LoaderService } from './loader.service'; export { MetaTagsService } from './meta-tags.service'; +export { MetadataRecordsService } from './metadata-records.service'; export { MyResourcesService } from './my-resources.service'; export { NodeLinksService } from './node-links.service'; export { ProjectRedirectDialogService } from './project-redirect-dialog.service'; diff --git a/src/app/shared/services/metadata-records.service.ts b/src/app/shared/services/metadata-records.service.ts new file mode 100644 index 000000000..f5b3472b7 --- /dev/null +++ b/src/app/shared/services/metadata-records.service.ts @@ -0,0 +1,23 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { MetadataRecordFormat } from '../enums'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class MetadataRecordsService { + private readonly http: HttpClient = inject(HttpClient); + + metadataRecordUrl(osfid: string, format: MetadataRecordFormat): string { + return `${environment.webUrl}/metadata/${osfid}/?format=${format}`; + } + + getMetadataRecord(osfid: string, format: MetadataRecordFormat): Observable { + return this.http.get(this.metadataRecordUrl(osfid, format), { responseType: 'text' }); + } +} From 336b2abc691112c8592af1ca60d7e7b0e6a8107c Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 10 Sep 2025 11:21:39 -0400 Subject: [PATCH 2/5] feat(metadata): get json-ld from backend --- .../file-detail/file-detail.component.ts | 1 + .../preprint-details.component.ts | 2 +- .../features/registry/registry.component.ts | 1 + .../models/meta-tags/meta-tags-data.model.ts | 1 + src/app/shared/services/meta-tags.service.ts | 87 ++++++++++--------- 5 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index b265a7919..1d55f5b6b 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -187,6 +187,7 @@ export class FileDetailComponent { const file = this.file(); if (!file) return null; return { + osfGuid: file.guid, title: this.fileCustomMetadata()?.title || file.name, description: this.fileCustomMetadata()?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 4249206eb..c0a186b1e 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -361,12 +361,12 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private setMetaTags() { this.metaTags.updateMetaTags( { + osfGuid: this.preprint()?.id, title: this.preprint()?.title, description: this.preprint()?.description, publishedDate: this.datePipe.transform(this.preprint()?.datePublished, 'yyyy-MM-dd'), modifiedDate: this.datePipe.transform(this.preprint()?.dateModified, 'yyyy-MM-dd'), url: pathJoin(environment.webUrl, this.preprint()?.id ?? ''), - identifier: this.preprint()?.id, doi: this.preprint()?.doi, keywords: this.preprint()?.tags, siteName: 'OSF', diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 03d72e984..710faef2b 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -44,6 +44,7 @@ export class RegistryComponent { private setMetaTags(): void { this.metaTags.updateMetaTags( { + osfGuid: this.registry()?.id, title: this.registry()?.title, description: this.registry()?.description, publishedDate: this.datePipe.transform(this.registry()?.dateRegistered, 'yyyy-MM-dd'), diff --git a/src/app/shared/models/meta-tags/meta-tags-data.model.ts b/src/app/shared/models/meta-tags/meta-tags-data.model.ts index d6c59b931..b10bf52ba 100644 --- a/src/app/shared/models/meta-tags/meta-tags-data.model.ts +++ b/src/app/shared/models/meta-tags/meta-tags-data.model.ts @@ -5,6 +5,7 @@ export type Content = string | number | null | undefined | MetaTagAuthor; export type DataContent = Content | Content[]; export interface MetaTagsData { + osfGuid?: string; title?: DataContent; type?: DataContent; description?: DataContent; diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index f789e722f..0335cc64d 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -1,7 +1,12 @@ +import { catchError, map, Observable, of, switchMap, tap } from 'rxjs'; + import { DOCUMENT } from '@angular/common'; -import { DestroyRef, Inject, Injectable } from '@angular/core'; +import { DestroyRef, Inject, inject, Injectable } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; +import { MetadataRecordFormat } from '@osf/shared/enums'; +import { MetadataRecordsService } from '@osf/shared/services'; + import { Content, DataContent, HeadTagDef, MetaTagAuthor, MetaTagsData } from '../models/meta-tags'; import { environment } from 'src/environments/environment'; @@ -10,6 +15,8 @@ import { environment } from 'src/environments/environment'; providedIn: 'root', }) export class MetaTagsService { + metadataRecords: MetadataRecordsService = inject(MetadataRecordsService); + private readonly defaultMetaTags: MetaTagsData = { type: 'article', description: 'Hosted on the OSF', @@ -80,13 +87,42 @@ export class MetaTagsService { private applyMetaTagsData(metaTagsData: MetaTagsData) { const combinedData = { ...this.defaultMetaTags, ...metaTagsData }; const headTags = this.getHeadTags(combinedData); - this.applyHeadTags(headTags); - this.dispatchZoteroEvent(); + of(metaTagsData.osfGuid) + .pipe( + switchMap( + (osfid) => + osfid // with an osf id, try getting schema.org json-ld from backend + ? this.getSchemaDotOrgJsonLdHeadTag(osfid).pipe( + tap((jsonLdHeadTag) => { + if (jsonLdHeadTag) { + headTags.push(jsonLdHeadTag); + } + }), + catchError(() => of(null)) // if it doesn't work, ignore and continue with given head tags + ) + : of(null) // without osfid, continue with only given head tags + ), + tap(() => this.applyHeadTags(headTags)), + tap(() => this.dispatchZoteroEvent()) + ) + .subscribe(); } - private getHeadTags(metaTagsData: MetaTagsData): HeadTagDef[] { - const headTags: HeadTagDef[] = []; + private getSchemaDotOrgJsonLdHeadTag(osfid: string): Observable { + return this.metadataRecords.getMetadataRecord(osfid, MetadataRecordFormat.SchemaDotOrgDataset).pipe( + map((jsonLd) => + jsonLd + ? { + type: 'script' as const, + attrs: { type: 'application/ld+json' }, + content: jsonLd, + } + : null + ) + ); + } + private getHeadTags(metaTagsData: MetaTagsData): HeadTagDef[] { const identifiers = this.toArray(metaTagsData.url) .concat(this.toArray(metaTagsData.doi)) .concat(this.toArray(metaTagsData.identifier)); @@ -112,7 +148,7 @@ export class MetaTagsService { 'dct.created': metaTagsData.publishedDate, 'dc.publisher': metaTagsData.siteName, 'dc.language': metaTagsData.language, - 'dc.contributor': metaTagsData.contributors, + 'dc.creator': metaTagsData.contributors, 'dc.subject': metaTagsData.keywords, // Open Graph/Facebook @@ -140,7 +176,7 @@ export class MetaTagsService { 'twitter:image:alt': metaTagsData.imageAlt, }; - const metaTagsHeadTags = Object.entries(metaTagsDefs) + return Object.entries(metaTagsDefs) .reduce((acc: HeadTagDef[], [name, content]) => { if (content) { const contentArray = this.toArray(content); @@ -156,45 +192,10 @@ export class MetaTagsService { return acc; }, []) .filter((tag) => tag.attrs.content); - - headTags.push(...metaTagsHeadTags); - - if (metaTagsData.contributors) { - headTags.push(this.buildPersonScriptTag(metaTagsData.contributors)); - } - - return headTags; - } - - private buildPersonScriptTag(contributors: DataContent): HeadTagDef { - const contributorArray = this.toArray(contributors); - const contributor = contributorArray - .filter((person): person is MetaTagAuthor => typeof person === 'object' && person !== null) - .map((person) => ({ - '@type': 'schema:Person', - name: person.fullName, - givenName: person.givenName, - familyName: person.familyName, - })); - - return { - type: 'script', - content: JSON.stringify({ - '@context': { - dc: 'http://purl.org/dc/elements/1.1/', - schema: 'http://schema.org', - }, - '@type': 'schema:CreativeWork', - contributor, - }), - attrs: { - type: 'application/ld+json', - }, - }; } private buildMetaTagContent(name: string, content: Content): Content { - if (['citation_author', 'dc.contributor'].includes(name) && typeof content === 'object') { + if (['citation_author', 'dc.creator'].includes(name) && typeof content === 'object') { const author = content as MetaTagAuthor; return `${author.familyName}, ${author.givenName}`; } From e0c659ed5d5a1d16924364df43a78eb5d2be8bf4 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 10 Sep 2025 12:33:17 -0400 Subject: [PATCH 3/5] fix(meta-tags): avoid repetitive meta-tag setting --- .../files/pages/file-detail/file-detail.component.ts | 6 ++++++ src/app/features/registry/registry.component.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 1d55f5b6b..1b995c8db 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -123,8 +123,10 @@ export class FileDetailComponent { cedarTemplates = select(MetadataSelectors.getCedarTemplates); isAnonymous = select(FilesSelectors.isFilesAnonymous); fileCustomMetadata = select(FilesSelectors.getFileCustomMetadata); + isFileCustomMetadataLoading = select(FilesSelectors.isFileMetadataLoading); resourceMetadata = select(FilesSelectors.getResourceMetadata); resourceContributors = select(FilesSelectors.getContributors); + isResourceContributorsLoading = select(FilesSelectors.isResourceContributorsLoading); safeLink: SafeResourceUrl | null = null; resourceId = ''; @@ -184,11 +186,15 @@ export class FileDetailComponent { }); private readonly metaTagsData = computed(() => { + if (this.isFileLoading() || this.isFileCustomMetadataLoading() || this.isResourceContributorsLoading()) { + return null; + } const file = this.file(); if (!file) return null; return { osfGuid: file.guid, title: this.fileCustomMetadata()?.title || file.name, + type: this.fileCustomMetadata()?.resourceTypeGeneral, description: this.fileCustomMetadata()?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), url: pathJoin(environment.webUrl, this.fileGuid), diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 710faef2b..cfb39b7da 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -30,11 +30,12 @@ export class RegistryComponent { private readonly destroyRef = inject(DestroyRef); readonly registry = select(RegistryOverviewSelectors.getRegistry); + readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); constructor() { effect(() => { - if (this.registry()) { + if (!this.isRegistryLoading() && this.registry()) { this.setMetaTags(); } }); From 0865531ccbe0952127f20efb0eab6f8f82c972b7 Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 10 Sep 2025 12:35:02 -0400 Subject: [PATCH 4/5] feat(meta-tags): add meta tags to project overview page --- .../project-overview.component.spec.ts | 3 +- .../overview/project-overview.component.ts | 43 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index a2d3c2064..89d00c771 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -11,7 +11,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { ToastService } from '@osf/shared/services'; +import { MetaTagsService, ToastService } from '@osf/shared/services'; import { GetActivityLogs } from '@shared/stores/activity-logs'; import { ProjectOverviewComponent } from './project-overview.component'; @@ -37,6 +37,7 @@ describe('ProjectOverviewComponent', () => { { provide: DialogService, useValue: { open: () => ({ onClose: of(null) }) } }, { provide: TranslateService, useValue: { instant: (k: string) => k } }, { provide: ToastService, useValue: { showSuccess: jest.fn() } }, + { provide: MetaTagsService, useValue: { updateMetaTags: jest.fn() } }, ], }).compileComponents(); diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 2151e42fb..15c2fde18 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -7,7 +7,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { TagModule } from 'primeng/tag'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -32,7 +32,7 @@ import { import { Mode, ResourceType, UserPermissions } from '@osf/shared/enums'; import { hasViewOnlyParam, IS_XSMALL } from '@osf/shared/helpers'; import { MapProjectOverview } from '@osf/shared/mappers'; -import { ToastService } from '@osf/shared/services'; +import { MetaTagsService, ToastService } from '@osf/shared/services'; import { ClearCollections, ClearWiki, @@ -95,7 +95,7 @@ import { ViewOnlyLinkMessageComponent, ViewOnlyLinkMessageComponent, ], - providers: [DialogService], + providers: [DialogService, DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectOverviewComponent implements OnInit { @@ -108,6 +108,8 @@ export class ProjectOverviewComponent implements OnInit { private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); private readonly dataciteService = inject(DataciteService); + private readonly metaTags = inject(MetaTagsService); + private readonly datePipe = inject(DatePipe); isMobile = toSignal(inject(IS_XSMALL)); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); @@ -210,6 +212,41 @@ export class ProjectOverviewComponent implements OnInit { }; }); + private readonly effectMetaTags = effect(() => { + if (!this.isProjectLoading()) { + const metaTagsData = this.metaTagsData(); + if (metaTagsData) { + this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + } + } + }); + + private readonly metaTagsData = computed(() => { + const project = this.currentProject(); + if (!project) return null; + const keywords = [...(project.tags || [])]; + if (project.category) { + keywords.push(project.category); + } + return { + osfGuid: project.id, + title: project.title, + description: project.description, + url: project.links?.iri, + doi: project.doi, + license: project.license?.name, + publishedDate: this.datePipe.transform(project.dateCreated, 'yyyy-MM-dd'), + modifiedDate: this.datePipe.transform(project.dateModified, 'yyyy-MM-dd'), + keywords, + institution: project.affiliatedInstitutions?.map((institution) => institution.name), + contributors: project.contributors.map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })), + }; + }); + constructor() { this.setupCollectionsEffects(); this.setupCleanup(); From 8c4eda23f5b87ca86df2dcfc9a763be584d9165d Mon Sep 17 00:00:00 2001 From: abram axel booth Date: Wed, 10 Sep 2025 13:11:01 -0400 Subject: [PATCH 5/5] fix(tests): mockery --- .../pages/preprint-details/preprint-details.component.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 0b3b3b0d0..10dfb5eb4 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -17,6 +17,7 @@ import { ShareAndDownloadComponent } from '@osf/features/preprints/components/pr import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; import { MOCK_PROVIDER, MOCK_STORE, TranslateServiceMock } from '@shared/mocks'; +import { MetaTagsService } from '@shared/services'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { PreprintDetailsComponent } from './preprint-details.component'; @@ -71,6 +72,7 @@ describe('PreprintDetailsComponent', () => { MockProvider(Router), MockProvider(ActivatedRoute, mockRoute), TranslateServiceMock, + MockProvider(MetaTagsService), ], }).compileComponents();