diff --git a/.husky/pre-push b/.husky/pre-push index 5a281490a..4bc89d416 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -17,3 +17,4 @@ npm run test:check-coverage-thresholds || { #printf "\n\nPlease address them before proceeding.\n\n\n\n" # exit 1 } + diff --git a/package-lock.json b/package-lock.json index d4639d509..08471af56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "@citation-js/core": "^0.7.18", + "@citation-js/plugin-csl": "^0.7.18", "@fortawesome/fontawesome-free": "^6.7.2", "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", @@ -2817,6 +2819,55 @@ "dev": true, "license": "MIT" }, + "node_modules/@citation-js/core": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz", + "integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==", + "license": "MIT", + "dependencies": { + "@citation-js/date": "^0.5.0", + "@citation-js/name": "^0.4.2", + "fetch-ponyfill": "^7.1.0", + "sync-fetch": "^0.4.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@citation-js/date": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz", + "integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@citation-js/name": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz", + "integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@citation-js/plugin-csl": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.18.tgz", + "integrity": "sha512-cJcOdEZurmtIxNj0d4cOERHpVQJB/mN3YPSDNqfI/xTFRN3bWDpFAsaqubPtMO2ZPpoDS+ZGIP1kggbwCfMmlA==", + "license": "MIT", + "dependencies": { + "@citation-js/date": "^0.5.0", + "citeproc": "^2.4.6" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@citation-js/core": "^0.7.0" + } + }, "node_modules/@commitlint/cli": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", @@ -9929,7 +9980,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -10223,7 +10273,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -10649,6 +10698,12 @@ "node": ">=8" } }, + "node_modules/citeproc": { + "version": "2.4.63", + "resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz", + "integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==", + "license": "CPAL-1.0 OR AGPL-1.0" + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -12106,7 +12161,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12144,7 +12198,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13579,6 +13632,15 @@ } } }, + "node_modules/fetch-ponyfill": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz", + "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==", + "license": "MIT", + "dependencies": { + "node-fetch": "~2.6.1" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -14873,7 +14935,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -19075,6 +19136,48 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -21520,7 +21623,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sass": { @@ -23147,6 +23250,19 @@ "dev": true, "license": "MIT" }, + "node_modules/sync-fetch": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz", + "integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==", + "license": "MIT", + "dependencies": { + "buffer": "^5.7.1", + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", diff --git a/package.json b/package.json index e86d48ab3..190ded296 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "@citation-js/core": "^0.7.18", + "@citation-js/plugin-csl": "^0.7.18", "@fortawesome/fontawesome-free": "^6.7.2", "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", diff --git a/src/@types/citation-js.d.ts b/src/@types/citation-js.d.ts new file mode 100644 index 000000000..08d254d03 --- /dev/null +++ b/src/@types/citation-js.d.ts @@ -0,0 +1,29 @@ +declare module '@citation-js/core' { + export class Cite { + constructor(data?: unknown); + + format( + type: string, + options?: { + format?: string; + template?: string; + lang?: string; + } + ): string; + + static plugins: { + config: { + get(name: string): { + templates: { + add(id: string, template: string): void; + has(id: string): boolean; + }; + }; + }; + }; + } + + export = Cite; +} + +declare module '@citation-js/plugin-csl'; diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html index 6a713ac4a..0c5b0319b 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.html @@ -56,6 +56,22 @@ > + + + + + + diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts index 5f0b78b80..cf4c4739d 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.ts @@ -80,4 +80,6 @@ export class ShareAndDownloadComponent { twitterShareLink = computed(() => this.shareLinks()?.twitter || ''); facebookShareLink = computed(() => this.shareLinks()?.facebook || ''); linkedInShareLink = computed(() => this.shareLinks()?.linkedIn || ''); + mastodonShareLink = computed(() => this.shareLinks()?.mastodon || ''); + blueskyShareLink = computed(() => this.shareLinks()?.bluesky || ''); } diff --git a/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.html b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.html new file mode 100644 index 000000000..406b386c9 --- /dev/null +++ b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.html @@ -0,0 +1,44 @@ +
+

{{ addon().displayName }}

+ + @if (!citationItems().length && !collectionItems().length && !isOperationInvocationSubmitting()) { + {{ 'project.overview.metadata.noCitations' | translate }} + } @else { + + + {{ selectedOption.label }} + + + + @if ((formattedCitationItems().length || collectionItems().length) && !isOperationInvocationSubmitting()) { +
+ @for (item of formattedCitationItems(); track item.item.itemId) { + + } + @for (collection of collectionItems(); track collection.itemId) { + + } +
+ } @else if (isOperationInvocationSubmitting()) { + + } + } +
diff --git a/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.scss b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.scss new file mode 100644 index 000000000..24aa84b52 --- /dev/null +++ b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.scss @@ -0,0 +1,11 @@ +@use "/styles/mixins" as mix; + +.citation-addon-card { + border: 1px solid var(--grey-2); + border-radius: mix.rem(12px); +} + +.citations-container { + max-height: mix.rem(300px); + overflow-y: auto; +} diff --git a/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.spec.ts b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.spec.ts new file mode 100644 index 000000000..b0b072344 --- /dev/null +++ b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CitationAddonCardComponent } from './citation-addon-card.component'; + +describe.skip('CitationAddonCardComponent', () => { + let component: CitationAddonCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CitationAddonCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CitationAddonCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.ts b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.ts new file mode 100644 index 000000000..e0c9ac1bc --- /dev/null +++ b/src/app/features/project/overview/components/citation-addon-card/citation-addon-card.component.ts @@ -0,0 +1,194 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Select, SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; + +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + OnInit, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { DEFAULT_CITATION_STYLE } from '@osf/features/project/overview/constants'; +import { OperationNames, StorageItemType } from '@shared/enums'; +import { formatCitation, getItemUrl } from '@shared/helpers'; +import { CitationStyle, ConfiguredAddonModel, CustomOption, StorageItem } from '@shared/models'; +import { AddonOperationInvocationService, CslStyleManagerService } from '@shared/services'; +import { CitationsSelectors, GetCitationStyles } from '@shared/stores'; +import { AddonsSelectors, CreateCitationAddonOperationInvocation } from '@shared/stores/addons'; + +import '@citation-js/plugin-csl'; + +import { FormattedCitationItem } from '../../models'; +import { CitationCollectionItemComponent } from '../citation-collection-item/citation-collection-item.component'; +import { CitationItemComponent } from '../citation-item/citation-item.component'; + +@Component({ + selector: 'osf-citation-addon-card', + imports: [Select, TranslatePipe, CitationItemComponent, CitationCollectionItemComponent, Skeleton], + templateUrl: './citation-addon-card.component.html', + styleUrl: './citation-addon-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CitationAddonCardComponent implements OnInit { + private readonly operationInvocationService = inject(AddonOperationInvocationService); + private readonly cslStyleManager = inject(CslStyleManagerService); + private readonly destroyRef = inject(DestroyRef); + private readonly filterSubject = new Subject(); + + readonly addon = input.required(); + + readonly allCitationOperationInvocations = select(AddonsSelectors.getAllCitationOperationInvocations); + + readonly operationInvocation = computed(() => { + const addonId = this.addon().id; + const invocations = this.allCitationOperationInvocations(); + return invocations[addonId]?.data ?? null; + }); + + readonly isOperationInvocationSubmitting = computed(() => { + const addonId = this.addon().id; + const invocations = this.allCitationOperationInvocations(); + return invocations[addonId]?.isSubmitting ?? false; + }); + + readonly citationStyles = select(CitationsSelectors.getCitationStyles); + readonly isCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); + + readonly citationStylesOptions = signal[]>([]); + readonly selectedCitationStyle = signal(DEFAULT_CITATION_STYLE); + readonly isStyleLoading = signal(false); + + readonly filterMessage = computed(() => + this.isCitationStylesLoading() + ? 'project.overview.metadata.citationLoadingPlaceholder' + : 'project.overview.metadata.noCitationStylesFound' + ); + + readonly actions = createDispatchMap({ + createCitationAddonOperationInvocation: CreateCitationAddonOperationInvocation, + getCitationStyles: GetCitationStyles, + }); + + readonly collectionItems = computed(() => { + const invocation = this.operationInvocation(); + const result = invocation?.operationResult ?? []; + return result.filter((item) => item.itemType === StorageItemType.Collection); + }); + + readonly citationItems = computed(() => { + const invocation = this.operationInvocation(); + const result = invocation?.operationResult ?? []; + return result.filter((item) => item.itemType === StorageItemType.Document && !!item.csl); + }); + + readonly formattedCitationItems = computed(() => { + const items = this.citationItems(); + const style = this.selectedCitationStyle(); + + return items.map((item) => ({ + item, + formattedCitation: formatCitation(item, style), + itemUrl: getItemUrl(item), + })); + }); + + constructor() { + this.setupFilterDebounce(); + this.setupCitationStylesEffect(); + this.setupCleanup(); + } + + ngOnInit(): void { + this.loadCitationData(); + this.initializeCitationStyles(); + } + + private loadCitationData(): void { + const addon = this.addon(); + const payload = this.operationInvocationService.createOperationInvocationPayload( + addon, + OperationNames.LIST_COLLECTION_ITEMS, + addon.selectedStorageItemId + ); + + this.actions.createCitationAddonOperationInvocation(payload, addon.id); + } + + private initializeCitationStyles(): void { + this.actions.getCitationStyles(''); + this.cslStyleManager + .ensureStyleLoaded(DEFAULT_CITATION_STYLE) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } + + handleCitationStyleFilterSearch(event: SelectFilterEvent): void { + event.originalEvent.preventDefault(); + this.filterSubject.next(event.filter); + } + + handleCitationStyleChange(event: SelectChangeEvent): void { + const styleId = event.value.id; + this.loadCitationStyle(styleId); + } + + private loadCitationStyle(styleId: string): void { + this.isStyleLoading.set(true); + + this.cslStyleManager + .ensureStyleLoaded(styleId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.onStyleLoadSuccess(styleId), + error: () => this.onStyleLoadError(styleId), + }); + } + + private onStyleLoadSuccess(styleId: string): void { + this.selectedCitationStyle.set(styleId); + this.isStyleLoading.set(false); + } + + private onStyleLoadError(styleId: string): void { + this.selectedCitationStyle.set(styleId); + this.isStyleLoading.set(false); + } + + private setupFilterDebounce(): void { + this.filterSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((filterValue) => { + this.actions.getCitationStyles(filterValue); + }); + } + + private setupCitationStylesEffect(): void { + effect(() => { + const styles = this.citationStyles(); + + const options = styles.map((style: CitationStyle) => ({ + label: style.title, + value: style, + })); + this.citationStylesOptions.set(options); + }); + } + + private setupCleanup(): void { + this.destroyRef.onDestroy(() => { + this.cslStyleManager.clearCache(); + }); + } +} diff --git a/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.html b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.html new file mode 100644 index 000000000..6bf42233e --- /dev/null +++ b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.html @@ -0,0 +1,40 @@ +
+
+
+ @if (isLoading()) { + + } @else { + + } + + + {{ collection().itemName }} + +
+
+ + @if (isExpanded()) { + @for (collectionChild of collectionChildren(); track collectionChild.item.itemId) { + + } + @for (formattedDoc of formattedDocumentChildren(); track formattedDoc.item.itemId) { + + } + } +
diff --git a/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.scss b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.spec.ts b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.spec.ts new file mode 100644 index 000000000..50b2825db --- /dev/null +++ b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.spec.ts @@ -0,0 +1,40 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IconComponent } from '@shared/components'; +import { StorageItemType } from '@shared/enums'; +import { AddonOperationInvocationService, AddonsService } from '@shared/services'; + +import { CitationItemComponent } from '../citation-item/citation-item.component'; + +import { CitationCollectionItemComponent } from './citation-collection-item.component'; + +describe.skip('CitationCollectionItemComponent', () => { + let component: CitationCollectionItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CitationCollectionItemComponent, ...MockComponents(IconComponent, CitationItemComponent)], + providers: [MockProvider(AddonOperationInvocationService), MockProvider(AddonsService)], + }).compileComponents(); + + fixture = TestBed.createComponent(CitationCollectionItemComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('addon', { + id: '1', + name: 'Test Addon', + } as any); + fixture.componentRef.setInput('collection', { + itemId: '1', + itemName: 'Test Collection', + itemType: StorageItemType.Collection, + } as any); + fixture.componentRef.setInput('selectedCitationStyle', 'apa'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.ts b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.ts new file mode 100644 index 000000000..4674e1bec --- /dev/null +++ b/src/app/features/project/overview/components/citation-collection-item/citation-collection-item.component.ts @@ -0,0 +1,148 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, OnInit, signal } from '@angular/core'; + +import { IconComponent } from '@shared/components'; +import { OperationNames, StorageItemType } from '@shared/enums'; +import { formatCitation, getItemUrl } from '@shared/helpers'; +import { ConfiguredAddonModel, OperationInvocation, StorageItem } from '@shared/models'; +import { AddonOperationInvocationService, AddonsService } from '@shared/services'; + +import { AddonTreeItem, FormattedCitationItem } from '../../models'; +import { CitationItemComponent } from '../citation-item/citation-item.component'; + +@Component({ + selector: 'osf-citation-collection-item', + imports: [IconComponent, CitationItemComponent], + templateUrl: './citation-collection-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CitationCollectionItemComponent implements OnInit { + private readonly operationInvocationService = inject(AddonOperationInvocationService); + private readonly addonsService = inject(AddonsService); + + readonly addon = input.required(); + readonly collection = input.required(); + readonly level = input(0); + readonly selectedCitationStyle = input.required(); + + readonly treeItem = signal(null); + + readonly isExpanded = computed(() => this.treeItem()?.expanded ?? false); + readonly isLoading = computed(() => this.treeItem()?.loading ?? false); + readonly children = computed(() => this.treeItem()?.children ?? []); + + readonly formattedDocumentChildren = computed(() => { + const children = this.children(); + const style = this.selectedCitationStyle(); + + return children + .filter((child) => this.isDocument(child.item)) + .map((child) => ({ + item: child.item, + formattedCitation: formatCitation(child.item, style), + itemUrl: getItemUrl(child.item), + })); + }); + + readonly collectionChildren = computed(() => { + const children = this.children(); + return children.filter((child) => this.isCollection(child.item)); + }); + + ngOnInit(): void { + this.treeItem.set({ + item: this.collection(), + children: [], + expanded: false, + loading: false, + }); + } + + toggleExpand(): void { + const currentItem = this.treeItem(); + if (!currentItem) return; + + if (currentItem.expanded) { + this.collapseItem(currentItem); + return; + } + + if (currentItem.children.length === 0) { + this.loadAndExpandChildren(currentItem); + } else { + this.expandItem(currentItem); + } + } + + private collapseItem(item: AddonTreeItem): void { + this.treeItem.set({ + ...item, + expanded: false, + }); + } + + private expandItem(item: AddonTreeItem): void { + this.treeItem.set({ + ...item, + expanded: true, + }); + } + + private loadAndExpandChildren(item: AddonTreeItem): void { + this.setLoadingState(item, true); + + const payload = this.createLoadChildrenPayload(); + + this.addonsService.createAddonOperationInvocation(payload).subscribe({ + next: (result) => this.handleChildrenLoadSuccess(item, result), + error: () => this.handleChildrenLoadError(item), + }); + } + + private createLoadChildrenPayload() { + return this.operationInvocationService.createOperationInvocationPayload( + this.addon(), + OperationNames.LIST_COLLECTION_ITEMS, + this.collection().itemId || '' + ); + } + + private handleChildrenLoadSuccess(currentItem: AddonTreeItem, result: Partial): void { + if (result?.operationResult) { + const children = this.mapResultToTreeItems(result.operationResult); + this.treeItem.set({ + ...currentItem, + children, + expanded: true, + loading: false, + }); + } + } + + private handleChildrenLoadError(currentItem: AddonTreeItem): void { + this.setLoadingState(currentItem, false); + } + + private setLoadingState(item: AddonTreeItem, loading: boolean): void { + this.treeItem.set({ + ...item, + loading, + }); + } + + private mapResultToTreeItems(items: StorageItem[]): AddonTreeItem[] { + return items.map((item) => ({ + item, + children: [], + expanded: false, + loading: false, + })); + } + + private isCollection(item: StorageItem): boolean { + return item.itemType === StorageItemType.Collection; + } + + private isDocument(item: StorageItem): boolean { + return item.itemType === StorageItemType.Document && !!item.csl; + } +} diff --git a/src/app/features/project/overview/components/citation-item/citation-item.component.html b/src/app/features/project/overview/components/citation-item/citation-item.component.html new file mode 100644 index 000000000..bf9391592 --- /dev/null +++ b/src/app/features/project/overview/components/citation-item/citation-item.component.html @@ -0,0 +1,30 @@ +
+
+ + + {{ citation() }} + +
+
+ + + + + +
+
diff --git a/src/app/features/project/overview/components/citation-item/citation-item.component.scss b/src/app/features/project/overview/components/citation-item/citation-item.component.scss new file mode 100644 index 000000000..5df719069 --- /dev/null +++ b/src/app/features/project/overview/components/citation-item/citation-item.component.scss @@ -0,0 +1,14 @@ +.citation-item { + border-bottom: 1px solid var(--grey-2); +} + +.citation-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hidden-link { + pointer-events: none; + visibility: hidden; +} diff --git a/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts b/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts new file mode 100644 index 000000000..a3a66cd0e --- /dev/null +++ b/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts @@ -0,0 +1,30 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { Clipboard } from '@angular/cdk/clipboard'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IconComponent } from '@shared/components'; +import { TranslateServiceMock } from '@shared/mocks'; +import { ToastService } from '@shared/services'; + +import { CitationItemComponent } from './citation-item.component'; + +describe('CitationItemComponent', () => { + let component: CitationItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CitationItemComponent, ...MockComponents(IconComponent)], + providers: [TranslateServiceMock, MockProvider(Clipboard), MockProvider(ToastService)], + }).compileComponents(); + + fixture = TestBed.createComponent(CitationItemComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('citation', 'Test Citation'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/overview/components/citation-item/citation-item.component.ts b/src/app/features/project/overview/components/citation-item/citation-item.component.ts new file mode 100644 index 000000000..02eaa158c --- /dev/null +++ b/src/app/features/project/overview/components/citation-item/citation-item.component.ts @@ -0,0 +1,30 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Tooltip } from 'primeng/tooltip'; + +import { Clipboard } from '@angular/cdk/clipboard'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; + +import { IconComponent } from '@shared/components'; +import { ToastService } from '@shared/services'; + +@Component({ + selector: 'osf-citation-item', + imports: [TranslatePipe, IconComponent, Tooltip], + templateUrl: './citation-item.component.html', + styleUrl: './citation-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CitationItemComponent { + private readonly clipboard = inject(Clipboard); + private readonly toastService = inject(ToastService); + + readonly citation = input.required(); + readonly itemUrl = input(''); + readonly level = input(0); + + copyCitation(): void { + this.clipboard.copy(this.citation()); + this.toastService.showSuccess('settings.developerApps.messages.copied'); + } +} diff --git a/src/app/features/project/overview/components/index.ts b/src/app/features/project/overview/components/index.ts index bcecf171d..f1d14c573 100644 --- a/src/app/features/project/overview/components/index.ts +++ b/src/app/features/project/overview/components/index.ts @@ -1,4 +1,7 @@ export { AddComponentDialogComponent } from './add-component-dialog/add-component-dialog.component'; +export { CitationAddonCardComponent } from './citation-addon-card/citation-addon-card.component'; +export { CitationCollectionItemComponent } from './citation-collection-item/citation-collection-item.component'; +export { CitationItemComponent } from './citation-item/citation-item.component'; export { DeleteComponentDialogComponent } from './delete-component-dialog/delete-component-dialog.component'; export { DuplicateDialogComponent } from './duplicate-dialog/duplicate-dialog.component'; export { FilesWidgetComponent } from './files-widget/files-widget.component'; diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts index 0971085c5..a9090aa7e 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts @@ -281,6 +281,16 @@ export class OverviewToolbarComponent { icon: 'fab fa-facebook-f', url: shareLinks.facebook, }, + { + label: 'project.overview.actions.socials.mastodon', + icon: 'fab fa-mastodon', + url: shareLinks.mastodon, + }, + { + label: 'project.overview.actions.socials.bluesky', + icon: 'fab fa-bluesky', + url: shareLinks.bluesky, + }, ]; } } diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.html b/src/app/features/project/overview/components/recent-activity/recent-activity.component.html index 4177d10ce..a8994a873 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.html +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.html @@ -1,16 +1,16 @@ -
-

{{ 'project.overview.recentActivity.title' | translate }}

+
+

{{ 'project.overview.recentActivity.title' | translate }}

@if (!isLoading()) { @if (formattedActivityLogs().length) { @for (activityLog of formattedActivityLogs(); track activityLog.id) { -
+
} } @else { -
+
{{ 'project.overview.recentActivity.noActivity' | translate }}
} @@ -25,7 +25,7 @@

{{ 'project.overview.recentActivity.title' | translate }}

/> } } @else { -
+
diff --git a/src/app/features/project/overview/constants/default-citation-style.const.ts b/src/app/features/project/overview/constants/default-citation-style.const.ts new file mode 100644 index 000000000..5c1ec3e71 --- /dev/null +++ b/src/app/features/project/overview/constants/default-citation-style.const.ts @@ -0,0 +1 @@ +export const DEFAULT_CITATION_STYLE = 'apa'; diff --git a/src/app/features/project/overview/constants/index.ts b/src/app/features/project/overview/constants/index.ts index 91276ae19..e9817e62a 100644 --- a/src/app/features/project/overview/constants/index.ts +++ b/src/app/features/project/overview/constants/index.ts @@ -1 +1,2 @@ +export * from './default-citation-style.const'; export * from './submission-review-status-options.const'; diff --git a/src/app/features/project/overview/models/addon-tree-item.model.ts b/src/app/features/project/overview/models/addon-tree-item.model.ts new file mode 100644 index 000000000..9d6855c72 --- /dev/null +++ b/src/app/features/project/overview/models/addon-tree-item.model.ts @@ -0,0 +1,8 @@ +import { StorageItem } from '@shared/models'; + +export interface AddonTreeItem { + item: StorageItem; + children: AddonTreeItem[]; + expanded: boolean; + loading: boolean; +} diff --git a/src/app/features/project/overview/models/formatted-citation-item.model.ts b/src/app/features/project/overview/models/formatted-citation-item.model.ts new file mode 100644 index 000000000..a6f96bf52 --- /dev/null +++ b/src/app/features/project/overview/models/formatted-citation-item.model.ts @@ -0,0 +1,7 @@ +import { StorageItem } from '@shared/models'; + +export interface FormattedCitationItem { + item: StorageItem; + formattedCitation: string; + itemUrl: string; +} diff --git a/src/app/features/project/overview/models/index.ts b/src/app/features/project/overview/models/index.ts index 9897ba5e9..7a6789398 100644 --- a/src/app/features/project/overview/models/index.ts +++ b/src/app/features/project/overview/models/index.ts @@ -1,3 +1,5 @@ +export * from './addon-tree-item.model'; +export * from './formatted-citation-item.model'; export * from './privacy-status.model'; export * from './project-overview.models'; export * from './socials-share-action-item.model'; diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 789f94490..373a7dbf2 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -66,6 +66,12 @@ } + @if (configuredCitationAddons().length) { + @for (addon of configuredCitationAddons(); track addon.id) { + + } + } +
diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index d7d2c1e67..ff1437552 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -34,13 +34,17 @@ import { hasViewOnlyParam } from '@osf/shared/helpers'; import { MapProjectOverview } from '@osf/shared/mappers'; import { CustomDialogService, MetaTagsService, ToastService } from '@osf/shared/services'; import { + AddonsSelectors, ClearCollections, + ClearConfiguredAddons, ClearWiki, CollectionsSelectors, CurrentResourceSelectors, FetchSelectedSubjects, + GetAddonsResourceReference, GetBookmarksCollectionId, GetCollectionProvider, + GetConfiguredCitationAddons, GetConfiguredStorageAddons, GetHomeWiki, GetLinkedResources, @@ -60,6 +64,7 @@ import { DataciteService } from '@shared/services/datacite/datacite.service'; import { OverviewParentProjectComponent } from './components/overview-parent-project/overview-parent-project.component'; import { + CitationAddonCardComponent, FilesWidgetComponent, LinkedResourcesComponent, OverviewComponentsComponent, @@ -100,6 +105,7 @@ import { FilesWidgetComponent, ViewOnlyLinkMessageComponent, OverviewParentProjectComponent, + CitationAddonCardComponent, ], providers: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, @@ -133,6 +139,9 @@ export class ProjectOverviewComponent implements OnInit { isWikiEnabled = select(ProjectOverviewSelectors.isWikiEnabled); parentProject = select(ProjectOverviewSelectors.getParentProject); isParentProjectLoading = select(ProjectOverviewSelectors.getParentProjectLoading); + addonsResourceReference = select(AddonsSelectors.getAddonsResourceReference); + configuredCitationAddons = select(AddonsSelectors.getConfiguredCitationAddons); + operationInvocation = select(AddonsSelectors.getOperationInvocation); private readonly actions = createDispatchMap({ getProject: GetProjectById, @@ -148,10 +157,13 @@ export class ProjectOverviewComponent implements OnInit { clearWiki: ClearWiki, clearCollections: ClearCollections, clearCollectionModeration: ClearCollectionModeration, + clearConfiguredAddons: ClearConfiguredAddons, getComponentsTree: GetResourceWithChildren, getConfiguredStorageAddons: GetConfiguredStorageAddons, getSubjects: FetchSelectedSubjects, getParentProject: GetParentProject, + getAddonsResourceReference: GetAddonsResourceReference, + getConfiguredCitationAddons: GetConfiguredCitationAddons, }); readonly activityPageSize = 5; @@ -254,6 +266,7 @@ export class ProjectOverviewComponent implements OnInit { this.setupCleanup(); this.setupProjectEffects(); this.setupRouteChangeListener(); + this.setupAddonsEffects(); effect(() => { if (!this.isProjectLoading()) { @@ -366,6 +379,7 @@ export class ProjectOverviewComponent implements OnInit { skip(1), tap((projectId) => { this.actions.clearProjectOverview(); + this.actions.clearConfiguredAddons(); this.actions.getProject(projectId); this.actions.getBookmarksId(); this.actions.getComponents(projectId); @@ -383,6 +397,23 @@ export class ProjectOverviewComponent implements OnInit { this.actions.clearWiki(); this.actions.clearCollections(); this.actions.clearCollectionModeration(); + this.actions.clearConfiguredAddons(); + }); + } + + private setupAddonsEffects(): void { + effect(() => { + const currentProject = this.currentProject(); + if (currentProject && !this.addonsResourceReference().length) { + this.actions.getAddonsResourceReference(currentProject.id); + } + }); + + effect(() => { + const resourceReference = this.addonsResourceReference(); + if (resourceReference.length) { + this.actions.getConfiguredCitationAddons(resourceReference[0].id); + } }); } } diff --git a/src/app/shared/constants/built-in-citation-styles.const.ts b/src/app/shared/constants/built-in-citation-styles.const.ts new file mode 100644 index 000000000..0e8fc0b2f --- /dev/null +++ b/src/app/shared/constants/built-in-citation-styles.const.ts @@ -0,0 +1 @@ +export const BUILT_IN_STYLES = ['apa', 'vancouver', 'harvard1']; diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index 2b610609d..ea425dd27 100644 --- a/src/app/shared/constants/index.ts +++ b/src/app/shared/constants/index.ts @@ -1,6 +1,7 @@ export * from './addon-terms.const'; export * from './addons-category-options.const'; export * from './addons-tab-options.const'; +export * from './built-in-citation-styles.const'; export * from './contributors.constants'; export * from './default-citation-titles.const'; export * from './default-table-params.constants'; diff --git a/src/app/shared/constants/social-share.config.ts b/src/app/shared/constants/social-share.config.ts index 09a9d5b74..88f7d17e2 100644 --- a/src/app/shared/constants/social-share.config.ts +++ b/src/app/shared/constants/social-share.config.ts @@ -2,5 +2,7 @@ export const SOCIAL_SHARE_URLS = { email: 'mailto:', twitter: { preview_url: 'https://twitter.com/intent/tweet', viaHandle: 'OsfFramework' }, facebook: 'https://www.facebook.com/sharer/sharer.php', - linkedIn: 'https://www.linkedin.com/shareArticle', + linkedIn: 'https://www.linkedin.com/sharing/share-offsite', + mastodon: 'https://mastodonshare.com', + bluesky: 'https://bsky.app/intent/compose', }; diff --git a/src/app/shared/enums/storage-item-type.enum.ts b/src/app/shared/enums/storage-item-type.enum.ts index ece308216..91ab6cfcf 100644 --- a/src/app/shared/enums/storage-item-type.enum.ts +++ b/src/app/shared/enums/storage-item-type.enum.ts @@ -1,4 +1,6 @@ export enum StorageItemType { Folder = 'FOLDER', Resource = 'RESOURCE', + Document = 'DOCUMENT', + Collection = 'COLLECTION', } diff --git a/src/app/shared/helpers/citation-formatter.helper.ts b/src/app/shared/helpers/citation-formatter.helper.ts new file mode 100644 index 000000000..5591e8290 --- /dev/null +++ b/src/app/shared/helpers/citation-formatter.helper.ts @@ -0,0 +1,25 @@ +import { StorageItem } from '@shared/models'; + +import { Cite } from '@citation-js/core'; + +export function formatCitation(item: StorageItem, style: string): string { + if (!item.csl) { + return item.itemName || ''; + } + + try { + const cite = new Cite(item.csl); + const citation = cite.format('bibliography', { + format: 'text', + template: style, + lang: 'en-US', + }); + return citation.trim(); + } catch { + return item.itemName || ''; + } +} + +export function getItemUrl(item: StorageItem): string { + return (item.csl?.['URL'] as string) || ''; +} diff --git a/src/app/shared/helpers/index.ts b/src/app/shared/helpers/index.ts index 315006b8e..468122427 100644 --- a/src/app/shared/helpers/index.ts +++ b/src/app/shared/helpers/index.ts @@ -5,6 +5,7 @@ export * from './breakpoints.tokens'; export * from './browser-tab.helper'; export * from './camel-case'; export * from './camel-case-to-normal.helper'; +export * from './citation-formatter.helper'; export * from './convert-to-snake-case.helper'; export * from './custom-form-validators.helper'; export * from './find-changed-fields'; diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 81f5ac104..fa3c4d73d 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -109,6 +109,7 @@ export class AddonMapper { itemLink: item.item_link, canBeRoot: item.can_be_root ?? true, mayContainRootCandidates: item.may_contain_root_candidates ?? isLinkAddon, + csl: item.csl, })) : [ { @@ -118,6 +119,7 @@ export class AddonMapper { itemLink: operationResult.item_link, canBeRoot: operationResult.can_be_root ?? true, mayContainRootCandidates: operationResult.may_contain_root_candidates ?? isLinkAddon, + csl: operationResult.csl, }, ]; diff --git a/src/app/shared/models/addons/addon-operations-json-api.models.ts b/src/app/shared/models/addons/addon-operations-json-api.models.ts index 7961ef7bb..bc979d21e 100644 --- a/src/app/shared/models/addons/addon-operations-json-api.models.ts +++ b/src/app/shared/models/addons/addon-operations-json-api.models.ts @@ -5,6 +5,7 @@ export interface StorageItemResponseJsonApi { item_link?: string; can_be_root?: boolean; may_contain_root_candidates?: boolean; + csl?: Record; } export interface OperationResultJsonApi { diff --git a/src/app/shared/models/addons/storage-item.model.ts b/src/app/shared/models/addons/storage-item.model.ts index 08f57270c..4c433c2b7 100644 --- a/src/app/shared/models/addons/storage-item.model.ts +++ b/src/app/shared/models/addons/storage-item.model.ts @@ -5,4 +5,5 @@ export interface StorageItem { itemLink?: string; canBeRoot?: boolean; mayContainRootCandidates?: boolean; + csl?: Record; } diff --git a/src/app/shared/models/social-share.model.ts b/src/app/shared/models/social-share.model.ts index 8f1c1e3c0..b44798fef 100644 --- a/src/app/shared/models/social-share.model.ts +++ b/src/app/shared/models/social-share.model.ts @@ -10,4 +10,6 @@ export interface SocialShareLinks { twitter: string; facebook: string; linkedIn: string; + mastodon: string; + bluesky: string; } diff --git a/src/app/shared/services/addons/addon-operation-invocation.service.ts b/src/app/shared/services/addons/addon-operation-invocation.service.ts index e252fc038..d44f9c317 100644 --- a/src/app/shared/services/addons/addon-operation-invocation.service.ts +++ b/src/app/shared/services/addons/addon-operation-invocation.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { OperationNames } from '@osf/shared/enums'; +import { OperationNames, StorageItemType } from '@osf/shared/enums'; import { isCitationAddon } from '@osf/shared/helpers'; import { AuthorizedAccountModel, ConfiguredAddonModel, OperationInvocationRequestJsonApi } from '@shared/models'; @@ -102,11 +102,18 @@ export class AddonOperationInvocationService { baseKwargs['item_id'] = itemId; } - const isChildOperation = - operationName === OperationNames.LIST_CHILD_ITEMS || operationName === OperationNames.LIST_COLLECTION_ITEMS; + const isCitationCollectionOperation = operationName === OperationNames.LIST_COLLECTION_ITEMS; + + if (isCitationCollectionOperation) { + return { + collection_id: itemId, + }; + } + + const isChildOperation = operationName === OperationNames.LIST_CHILD_ITEMS; if (isChildOperation) { - baseKwargs['item_type'] = 'FOLDER'; + baseKwargs['item_type'] = StorageItemType.Folder; } if (pageCursor) { diff --git a/src/app/shared/services/citations.service.ts b/src/app/shared/services/citations.service.ts index 567032c68..d9a4ac815 100644 --- a/src/app/shared/services/citations.service.ts +++ b/src/app/shared/services/citations.service.ts @@ -1,6 +1,6 @@ import { map, Observable } from 'rxjs'; -import { HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -23,6 +23,7 @@ import { JsonApiService } from './json-api.service'; }) export class CitationsService { private readonly jsonApiService = inject(JsonApiService); + private readonly http = inject(HttpClient); private readonly environment = inject(ENVIRONMENT); get apiUrl() { @@ -56,6 +57,11 @@ export class CitationsService { return this.jsonApiService.patch(`${this.apiUrl}/${payload.type}/${payload.id}/`, citationData); } + fetchCustomCitationFile(styleId: string): Observable { + const url = `/static/vendor/bower_components/styles/${styleId}.csl`; + return this.http.get(url, { responseType: 'text' }); + } + private getBaseCitationUrl(resourceType: ResourceType | string, resourceId: string): string { let resourceTypeString; diff --git a/src/app/shared/services/csl-style-manager.service.ts b/src/app/shared/services/csl-style-manager.service.ts new file mode 100644 index 000000000..794d530ab --- /dev/null +++ b/src/app/shared/services/csl-style-manager.service.ts @@ -0,0 +1,72 @@ +import { catchError, from, Observable, of, switchMap, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { BUILT_IN_STYLES } from '@shared/constants'; + +import { CitationsService } from './citations.service'; + +import * as Cite from '@citation-js/core'; + +@Injectable({ + providedIn: 'root', +}) +export class CslStyleManagerService { + private readonly citationsService = inject(CitationsService); + + private readonly loadedStyles = new Set(); + + private readonly builtInStyles = new Set(BUILT_IN_STYLES); + + ensureStyleLoaded(styleId: string): Observable { + if (this.builtInStyles.has(styleId)) { + return of(undefined); + } + + if (this.loadedStyles.has(styleId)) { + return of(undefined); + } + + if (this.isStyleRegistered(styleId)) { + this.loadedStyles.add(styleId); + return of(undefined); + } + + return this.citationsService.fetchCustomCitationFile(styleId).pipe( + switchMap((cslXml) => this.registerStyle(styleId, cslXml)), + tap(() => { + this.loadedStyles.add(styleId); + }), + catchError(() => { + this.loadedStyles.add(styleId); + return of(undefined); + }) + ); + } + + private registerStyle(styleId: string, cslXml: string): Observable { + return from( + Promise.resolve().then(() => { + try { + const config = Cite.plugins.config.get('@csl'); + config.templates.add(styleId, cslXml); + } catch (error) { + throw new Error(error?.toString()); + } + }) + ); + } + + private isStyleRegistered(styleId: string): boolean { + try { + const config = Cite.plugins.config.get('@csl'); + return config.templates.has(styleId); + } catch { + return false; + } + } + + clearCache(): void { + this.loadedStyles.clear(); + } +} diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 0f52b0dd9..fd60bfa4a 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -3,8 +3,10 @@ export * from './addons'; export { AnalyticsService } from './analytics.service'; export { BookmarksService } from './bookmarks.service'; export { BrandService } from './brand.service'; +export { CitationsService } from './citations.service'; export { CollectionsService } from './collections.service'; export { ContributorsService } from './contributors.service'; +export { CslStyleManagerService } from './csl-style-manager.service'; export { CustomConfirmationService } from './custom-confirmation.service'; export { CustomDialogService } from './custom-dialog.service'; export { DuplicatesService } from './duplicates.service'; diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts index 176fecbdb..20977c169 100644 --- a/src/app/shared/services/social-share.service.ts +++ b/src/app/shared/services/social-share.service.ts @@ -37,11 +37,21 @@ export class SocialShareService { generateLinkedInLink(content: ShareableContent): string { const url = encodeURIComponent(content.url); - const title = encodeURIComponent(content.title); - const summary = encodeURIComponent(content.description || content.title); - const source = encodeURIComponent('OSF'); - return `${SOCIAL_SHARE_URLS.linkedIn}?mini=true&url=${url}&title=${title}&summary=${summary}&source=${source}`; + return `${SOCIAL_SHARE_URLS.linkedIn}?url=${url}`; + } + + generateMastodonLink(content: ShareableContent): string { + const url = encodeURIComponent(content.url); + const text = encodeURIComponent(content.title); + + return `${SOCIAL_SHARE_URLS.mastodon}?url=${url}&text=${text}`; + } + + generateBlueskyLink(content: ShareableContent): string { + const text = encodeURIComponent(`${content.title} ${content.url}`); + + return `${SOCIAL_SHARE_URLS.bluesky}?text=${text}`; } generateAllSharingLinks(content: ShareableContent): SocialShareLinks { @@ -50,6 +60,8 @@ export class SocialShareService { twitter: this.generateTwitterLink(content), facebook: this.generateFacebookLink(content), linkedIn: this.generateLinkedInLink(content), + mastodon: this.generateMastodonLink(content), + bluesky: this.generateBlueskyLink(content), }; } diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 8a3d2bd07..24568c0d5 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -133,6 +133,15 @@ export class CreateAddonOperationInvocation { constructor(public payload: OperationInvocationRequestJsonApi) {} } +export class CreateCitationAddonOperationInvocation { + static readonly type = '[Addons] Create Citation Addon Operation Invocation'; + + constructor( + public payload: OperationInvocationRequestJsonApi, + public addonId: string + ) {} +} + export class ClearAuthorizedAddons { static readonly type = '[Addons] Clear Authorized Addons'; } diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index f5e2ef440..74e128b69 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -25,6 +25,7 @@ export interface AddonsStateModel { createdUpdatedConfiguredAddon: AsyncStateModel; operationInvocation: AsyncStateModel; selectedItemOperationInvocation: AsyncStateModel; + citationOperationInvocations: Record>; } export const ADDONS_DEFAULTS: AddonsStateModel = { @@ -110,4 +111,5 @@ export const ADDONS_DEFAULTS: AddonsStateModel = { isSubmitting: false, error: null, }, + citationOperationInvocations: {}, }; diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index f793dfc7a..153c9ee06 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -183,4 +183,23 @@ export class AddonsSelectors { static getDeleteStorageAddonSubmitting(state: AddonsStateModel): boolean { return state.createdUpdatedConfiguredAddon.isSubmitting || false; } + + @Selector([AddonsState]) + static getAllCitationOperationInvocations(state: AddonsStateModel): AddonsStateModel['citationOperationInvocations'] { + return state.citationOperationInvocations || {}; + } + + @Selector([AddonsState]) + static getCitationOperationInvocation(addonId: string): (state: AddonsStateModel) => OperationInvocation | null { + return createSelector([AddonsState], (state: AddonsStateModel): OperationInvocation | null => { + return state.citationOperationInvocations?.[addonId]?.data || null; + }); + } + + @Selector([AddonsState]) + static getCitationOperationInvocationSubmitting(addonId: string): (state: AddonsStateModel) => boolean { + return createSelector([AddonsState], (state: AddonsStateModel): boolean => { + return state.citationOperationInvocations?.[addonId]?.isSubmitting || false; + }); + } } diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index c3c7fd098..1b3afb253 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -15,6 +15,7 @@ import { ClearOperationInvocations, CreateAddonOperationInvocation, CreateAuthorizedAddon, + CreateCitationAddonOperationInvocation, CreateConfiguredAddon, DeleteAuthorizedAddon, DeleteConfiguredAddon, @@ -594,6 +595,62 @@ export class AddonsState { ); } + @Action(CreateCitationAddonOperationInvocation) + createCitationAddonOperationInvocation( + ctx: StateContext, + action: CreateCitationAddonOperationInvocation + ) { + const state = ctx.getState(); + const existingInvocation = state.citationOperationInvocations[action.addonId] || { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }; + + ctx.patchState({ + citationOperationInvocations: { + ...state.citationOperationInvocations, + [action.addonId]: { + ...existingInvocation, + isSubmitting: true, + }, + }, + }); + + return this.addonsService.createAddonOperationInvocation(action.payload).pipe( + tap((response) => { + const currentState = ctx.getState(); + ctx.patchState({ + citationOperationInvocations: { + ...currentState.citationOperationInvocations, + [action.addonId]: { + data: response, + isLoading: false, + isSubmitting: false, + error: null, + }, + }, + }); + }), + catchError((error) => { + const currentState = ctx.getState(); + ctx.patchState({ + citationOperationInvocations: { + ...currentState.citationOperationInvocations, + [action.addonId]: { + data: null, + isLoading: false, + isSubmitting: false, + error: error, + }, + }, + }); + return handleSectionError(ctx, 'citationOperationInvocations', error); + }) + ); + } + @Action(ClearAuthorizedAddons) clearAuthorizedAddons(ctx: StateContext) { ctx.patchState({ @@ -659,6 +716,7 @@ export class AddonsState { isLoading: false, error: null, }, + citationOperationInvocations: {}, }); } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 8aa227335..bb773b77a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -688,6 +688,10 @@ "title": "Recent Activity", "noActivity": "No recent activity" }, + "citations": { + "copyCitation": "Copy citation", + "viewOriginal": "View original file" + }, "collectionsModeration": { "pending": "Pending entry into\u00A0", "accepted": "Included in\u00A0", @@ -719,6 +723,7 @@ "customCitationPlaceholder": "Enter custom citation", "citeAs": "Cite as:", "noCitationStylesFound": "No results found", + "noCitations": "No citations", "affiliatedInstitutions": "Affiliated Institutions", "affiliatedInstitutionsDescription": "This is a service provided by the OSF and is automatically applied to your registration. If you are not sure if your institution has signed up for this service, you can look for their name in this list.", "noDescription": "No description", @@ -868,7 +873,9 @@ "email": "Email", "x": "X", "linkedIn": "LinkedIn", - "facebook": "Facebook" + "facebook": "Facebook", + "mastodon": "Mastodon", + "bluesky": "Bluesky" } }, "tooltips": {