diff --git a/src/app/features/moderation/collection-moderation.routes.ts b/src/app/features/moderation/collection-moderation.routes.ts index 92ef78df2..9264f9c16 100644 --- a/src/app/features/moderation/collection-moderation.routes.ts +++ b/src/app/features/moderation/collection-moderation.routes.ts @@ -4,6 +4,7 @@ import { Routes } from '@angular/router'; import { CollectionsModerationState } from '@osf/features/moderation/store/collections-moderation'; import { ResourceType } from '@osf/shared/enums'; +import { ActivityLogsState } from '@shared/stores/activity-logs'; import { CollectionsState } from '@shared/stores/collections'; import { ModeratorsState } from './store/moderators'; @@ -16,6 +17,7 @@ export const collectionModerationRoutes: Routes = [ import('@osf/features/moderation/pages/collection-moderation/collection-moderation.component').then( (m) => m.CollectionModerationComponent ), + providers: [provideStates([ActivityLogsState])], children: [ { path: '', 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 1b39472d6..09bacf33f 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,30 +1,36 @@ -
-

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

+
+

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

-
-
- Jeremy Wolfe removed tag example from - Project name example -
- - -
- -
-
- Jeremy Wolfe removed tag example from - Project name example -
- - -
+ @if (!isLoading()) { + @if (formattedActivityLogs().length) { + @for (activityLog of formattedActivityLogs(); track activityLog.id) { +
+
+ +
+ } + } @else { +
+ {{ 'project.overview.recentActivity.noActivity' | translate }} +
+ } -
-
- Jeremy Wolfe removed tag example from - Project name example + @if (totalCount() > pageSize()) { + + } + } @else { +
+ + + + +
- - -
+ }
diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss b/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss index caa0a897f..800d08df0 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss @@ -7,9 +7,13 @@ color: var.$dark-blue-1; &-activity { - height: mix.rem(35px); border-bottom: 1px solid var.$grey-2; + + .activity-date { + width: 30%; + } } + &-description { line-height: mix.rem(24px); } diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts b/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts index 1cf58fcdd..8a9586d9b 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts @@ -1,12 +1,57 @@ +import { createDispatchMap, select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { PaginatorState } from 'primeng/paginator'; +import { Skeleton } from 'primeng/skeleton'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { CustomPaginatorComponent } from '@shared/components'; +import { ActivityLogDisplayService } from '@shared/services'; +import { ActivityLogsSelectors, GetActivityLogs } from '@shared/stores/activity-logs'; @Component({ selector: 'osf-recent-activity-list', - imports: [TranslatePipe], + imports: [TranslatePipe, Skeleton, DatePipe, CustomPaginatorComponent], templateUrl: './recent-activity.component.html', styleUrl: './recent-activity.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RecentActivityComponent {} +export class RecentActivityComponent { + private readonly activityLogDisplayService = inject(ActivityLogDisplayService); + private readonly route = inject(ActivatedRoute); + + readonly pageSize = input.required(); + protected currentPage = signal(1); + protected activityLogs = select(ActivityLogsSelectors.getActivityLogs); + protected totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); + protected isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + protected firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize()); + + protected actions = createDispatchMap({ + getActivityLogs: GetActivityLogs, + }); + + protected formattedActivityLogs = computed(() => { + const logs = this.activityLogs(); + return logs.map((log) => ({ + ...log, + formattedActivity: this.activityLogDisplayService.getActivityDisplay(log), + })); + }); + + onPageChange(event: PaginatorState) { + if (event.page !== undefined) { + const pageNumber = event.page + 1; + this.currentPage.set(pageNumber); + + const projectId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; + if (projectId) { + this.actions.getActivityLogs(projectId, pageNumber.toString(), this.pageSize().toString()); + } + } + } +} diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 70c136e04..a9e0d5866 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -33,24 +33,35 @@ @if (status && isCollectionsRoute() && collectionProvider()) { @switch (status) { @case (SubmissionReviewStatus.Pending) { - - Pending: Pending entry into {{ collectionProvider()?.name }} + {{ 'project.overview.collectionsModeration.pending' | translate }} + + {{ collectionProvider()?.name }} } @case (SubmissionReviewStatus.Accepted) { - Accepted: Accepted entry into {{ collectionProvider()?.name }} + {{ 'project.overview.collectionsModeration.accepted' | translate }} + + {{ collectionProvider()?.name }} } @case (SubmissionReviewStatus.Rejected) { - Rejected: Rejected entry into {{ collectionProvider()?.name }} + {{ 'project.overview.collectionsModeration.rejected' | translate }} + + {{ collectionProvider()?.name }} } @case (SubmissionReviewStatus.Removed) { - Withdrawn: Entry withdrawn from {{ collectionProvider()?.name }} + {{ 'project.overview.collectionsModeration.removed' | translate }} + + {{ collectionProvider()?.name }} } } @@ -64,7 +75,7 @@ - +
diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 8c8d9d746..f4fc8f9ea 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -20,7 +20,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; import { IS_XSMALL } from '@osf/shared/helpers'; @@ -41,6 +41,7 @@ import { GetHomeWiki, GetLinkedResources, } from '@shared/stores'; +import { GetActivityLogs } from '@shared/stores/activity-logs'; import { ClearCollections } from '@shared/stores/collections'; import { @@ -83,6 +84,7 @@ import { ResourceMetadataComponent, TranslatePipe, Message, + RouterLink, ], providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, @@ -100,6 +102,8 @@ export class ProjectOverviewComponent implements OnInit { protected submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); protected collectionProvider = select(CollectionsSelectors.getCollectionProvider); protected currentReviewAction = select(CollectionsModerationSelectors.getCurrentReviewAction); + protected readonly activityPageSize = 5; + protected readonly activityDefaultPage = 1; protected actions = createDispatchMap({ getProject: GetProjectById, @@ -107,6 +111,7 @@ export class ProjectOverviewComponent implements OnInit { getHomeWiki: GetHomeWiki, getComponents: GetComponents, getLinkedProjects: GetLinkedResources, + getActivityLogs: GetActivityLogs, setProjectCustomCitation: SetProjectCustomCitation, getCollectionProvider: GetCollectionProvider, getCurrentReviewAction: GetSubmissionsReviewActions, @@ -195,6 +200,7 @@ export class ProjectOverviewComponent implements OnInit { this.actions.getHomeWiki(ResourceType.Project, projectId); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); + this.actions.getActivityLogs(projectId, this.activityDefaultPage.toString(), this.activityPageSize.toString()); } } diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 8d9961a60..d41a6d7e2 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -13,6 +13,7 @@ import { SubjectsState, ViewOnlyLinkState, } from '@osf/shared/stores'; +import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from './analytics/store'; import { ProjectFilesState } from './files/store'; @@ -32,7 +33,15 @@ export const projectRoutes: Routes = [ path: 'overview', loadComponent: () => import('../project/overview/project-overview.component').then((mod) => mod.ProjectOverviewComponent), - providers: [provideStates([NodeLinksState, CitationsState, CollectionsState, CollectionsModerationState])], + providers: [ + provideStates([ + NodeLinksState, + CitationsState, + CollectionsState, + CollectionsModerationState, + ActivityLogsState, + ]), + ], }, { path: 'metadata', diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.html b/src/app/shared/components/resource-metadata/resource-metadata.component.html index de8675640..db28c153f 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.html +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.html @@ -17,7 +17,7 @@

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

{{ 'project.overview.metadata.contributors' | translate }}

-
+
@for (contributor of resource.contributors; track contributor.id) {
{{ contributor.fullName }} diff --git a/src/app/shared/mappers/activity-logs.mapper.ts b/src/app/shared/mappers/activity-logs.mapper.ts new file mode 100644 index 000000000..0194cf38b --- /dev/null +++ b/src/app/shared/mappers/activity-logs.mapper.ts @@ -0,0 +1,155 @@ +import { ActivityLog, ActivityLogJsonApi, LogContributor, PaginatedData, ResponseJsonApi } from '@shared/models'; +import { LogContributorJsonApi } from '@shared/models/activity-logs/activity-logs-json-api.model'; + +export class ActivityLogsMapper { + static fromActivityLogJsonApi(log: ActivityLogJsonApi): ActivityLog { + return { + id: log.id, + type: log.type, + action: log.attributes.action, + date: log.attributes.date, + params: { + contributors: log.attributes.params.contributors.map((contributor) => this.fromContributorJsonApi(contributor)), + license: log.attributes.params.license, + tag: log.attributes.params.tag, + institution: log.attributes.params.institution, + paramsNode: { + id: log.attributes.params.params_node.id, + title: log.attributes.params.params_node.title, + }, + paramsProject: log.attributes.params.params_project, + pointer: log.attributes.params.pointer + ? { + category: log.attributes.params.pointer.category, + id: log.attributes.params.pointer.id, + title: log.attributes.params.pointer.title, + url: log.attributes.params.pointer.url, + } + : null, + preprintProvider: log.attributes.params.preprint_provider, + addon: log.attributes.params.addon, + anonymousLink: log.attributes.params.anonymous_link, + file: log.attributes.params.file, + wiki: log.attributes.params.wiki, + destination: log.attributes.params.destination, + identifiers: log.attributes.params.identifiers, + kind: log.attributes.params.kind, + oldPage: log.attributes.params.old_page, + page: log.attributes.params.page, + pageId: log.attributes.params.page_id, + path: log.attributes.params.path, + urls: log.attributes.params.urls, + preprint: log.attributes.params.preprint, + source: log.attributes.params.source, + titleNew: log.attributes.params.title_new, + titleOriginal: log.attributes.params.title_original, + updatedFields: log.attributes.params.updated_fields, + value: log.attributes.params.value, + version: log.attributes.params.version, + githubUser: log.attributes.params.github_user, + }, + embeds: log.embeds + ? { + originalNode: log.embeds.original_node?.data + ? { + id: log.embeds.original_node.data.id, + type: log.embeds.original_node.data.type, + title: log.embeds.original_node.data.attributes.title, + description: log.embeds.original_node.data.attributes.description, + category: log.embeds.original_node.data.attributes.category, + customCitation: log.embeds.original_node.data.attributes.custom_citation, + dateCreated: log.embeds.original_node.data.attributes.date_created, + dateModified: log.embeds.original_node.data.attributes.date_modified, + registration: log.embeds.original_node.data.attributes.registration, + preprint: log.embeds.original_node.data.attributes.preprint, + fork: log.embeds.original_node.data.attributes.fork, + collection: log.embeds.original_node.data.attributes.collection, + tags: log.embeds.original_node.data.attributes.tags, + accessRequestsEnabled: log.embeds.original_node.data.attributes.access_requests_enabled, + nodeLicense: log.embeds.original_node.data.attributes.node_license + ? { + copyrightHolders: log.embeds.original_node.data.attributes.node_license.copyright_holders, + year: log.embeds.original_node.data.attributes.node_license.year, + } + : { copyrightHolders: [], year: null }, + currentUserCanComment: log.embeds.original_node.data.attributes.current_user_can_comment, + currentUserPermissions: log.embeds.original_node.data.attributes.current_user_permissions, + currentUserIsContributor: log.embeds.original_node.data.attributes.current_user_is_contributor, + currentUserIsContributorOrGroupMember: + log.embeds.original_node.data.attributes.current_user_is_contributor_or_group_member, + wikiEnabled: log.embeds.original_node.data.attributes.wiki_enabled, + public: log.embeds.original_node.data.attributes.public, + subjects: log.embeds.original_node.data.attributes.subjects, + } + : undefined, + user: log.embeds.user?.data + ? { + id: log.embeds.user.data.id, + type: log.embeds.user.data.type, + fullName: log.embeds.user.data.attributes.full_name, + givenName: log.embeds.user.data.attributes.given_name, + middleNames: log.embeds.user.data.attributes.middle_names, + familyName: log.embeds.user.data.attributes.family_name, + suffix: log.embeds.user.data.attributes.suffix, + dateRegistered: log.embeds.user.data.attributes.date_registered, + active: log.embeds.user.data.attributes.active, + timezone: log.embeds.user.data.attributes.timezone, + locale: log.embeds.user.data.attributes.locale, + } + : undefined, + linkedNode: log.embeds.linked_node?.data + ? { + id: log.embeds.linked_node.data.id, + type: log.embeds.linked_node.data.type, + title: log.embeds.linked_node.data.attributes.title, + description: log.embeds.linked_node.data.attributes.description, + category: log.embeds.linked_node.data.attributes.category, + customCitation: log.embeds.linked_node.data.attributes.custom_citation, + dateCreated: log.embeds.linked_node.data.attributes.date_created, + dateModified: log.embeds.linked_node.data.attributes.date_modified, + registration: log.embeds.linked_node.data.attributes.registration, + preprint: log.embeds.linked_node.data.attributes.preprint, + fork: log.embeds.linked_node.data.attributes.fork, + collection: log.embeds.linked_node.data.attributes.collection, + tags: log.embeds.linked_node.data.attributes.tags, + accessRequestsEnabled: log.embeds.linked_node.data.attributes.access_requests_enabled, + nodeLicense: log.embeds.linked_node.data.attributes.node_license + ? { + copyrightHolders: log.embeds.linked_node.data.attributes.node_license.copyright_holders, + year: log.embeds.linked_node.data.attributes.node_license.year, + } + : { copyrightHolders: [], year: null }, + currentUserCanComment: log.embeds.linked_node.data.attributes.current_user_can_comment, + currentUserPermissions: log.embeds.linked_node.data.attributes.current_user_permissions, + currentUserIsContributor: log.embeds.linked_node.data.attributes.current_user_is_contributor, + currentUserIsContributorOrGroupMember: + log.embeds.linked_node.data.attributes.current_user_is_contributor_or_group_member, + wikiEnabled: log.embeds.linked_node.data.attributes.wiki_enabled, + public: log.embeds.linked_node.data.attributes.public, + subjects: log.embeds.linked_node.data.attributes.subjects, + } + : undefined, + } + : undefined, + }; + } + + static fromGetActivityLogsResponse(logs: ResponseJsonApi): PaginatedData { + return { + data: logs.data.map((log) => this.fromActivityLogJsonApi(log)), + totalCount: logs.meta.total ?? 0, + }; + } + + private static fromContributorJsonApi(contributor: LogContributorJsonApi): LogContributor { + return { + id: contributor.id, + fullName: contributor.full_name, + givenName: contributor.given_name, + middleNames: contributor.middle_names, + familyName: contributor.family_name, + unregisteredName: contributor.unregistered_name, + active: contributor.active, + }; + } +} diff --git a/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts b/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts new file mode 100644 index 000000000..4554cd5f3 --- /dev/null +++ b/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts @@ -0,0 +1,184 @@ +export interface ActivityLogJsonApi { + id: string; + type: string; + attributes: { + action: string; + date: string; + params: { + contributors: LogContributorJsonApi[]; + license?: string; + tag?: string; + institution?: { + id: string; + name: string; + }; + params_node: { + id: string; + title: string; + }; + params_project: null; + pointer: PointerJsonApi | null; + preprint_provider?: + | string + | { + url: string; + name: string; + } + | null; + addon?: string; + anonymous_link?: boolean; + file?: { + name: string; + url: string; + }; + wiki?: { + name: string; + url: string; + }; + destination?: { + materialized: string; + addon: string; + url: string; + }; + identifiers?: { + doi?: string; + ark?: string; + }; + kind?: string; + old_page?: string; + page?: string; + page_id?: string; + path?: string; + urls?: { + view: string; + }; + preprint?: string; + source?: { + materialized: string; + addon: string; + }; + title_new?: string; + title_original?: string; + updated_fields?: Record< + string, + { + new: string; + old: string; + } + >; + value?: string; + version?: string; + github_user?: string; + }; + }; + embeds?: { + original_node?: { + data: OriginalNodeEmbedsData; + }; + user?: { + data: UserEmbedsData; + }; + linked_node?: { + data: LinkedNodeEmbedsData; + }; + }; + meta: { + total: number; + }; +} + +interface PointerJsonApi { + category: string; + id: string; + title: string; + url: string; +} + +interface OriginalNodeEmbedsData { + id: string; + type: string; + attributes: { + title: string; + description: string; + category: string; + custom_citation: string | null; + date_created: string; + date_modified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + access_requests_enabled: boolean; + node_license: { + copyright_holders: string[]; + year: string | null; + } | null; + current_user_can_comment: boolean; + current_user_permissions: string[]; + current_user_is_contributor: boolean; + current_user_is_contributor_or_group_member: boolean; + wiki_enabled: boolean; + public: boolean; + subjects: { id: string; text: string }[][]; + }; +} + +interface UserEmbedsData { + id: string; + type: string; + attributes: { + full_name: string; + given_name: string; + middle_names: string; + family_name: string; + suffix: string; + date_registered: string; + active: boolean; + timezone: string; + locale: string; + }; +} + +interface LinkedNodeEmbedsData { + id: string; + type: string; + attributes: { + title: string; + description: string; + category: string; + custom_citation: string | null; + date_created: string; + date_modified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + access_requests_enabled: boolean; + node_license: { + copyright_holders: string[]; + year: string | null; + } | null; + current_user_can_comment: boolean; + current_user_permissions: string[]; + current_user_is_contributor: boolean; + current_user_is_contributor_or_group_member: boolean; + wiki_enabled: boolean; + public: boolean; + subjects: { + id: string; + text: string; + }[][]; + }; +} + +export interface LogContributorJsonApi { + id: string; + full_name: string; + given_name: string; + middle_names: string; + family_name: string; + unregistered_name: string | null; + active: boolean; +} diff --git a/src/app/shared/models/activity-logs/activity-logs.model.ts b/src/app/shared/models/activity-logs/activity-logs.model.ts new file mode 100644 index 000000000..34b133518 --- /dev/null +++ b/src/app/shared/models/activity-logs/activity-logs.model.ts @@ -0,0 +1,164 @@ +export interface ActivityLog { + id: string; + type: string; + action: string; + date: string; + params: { + contributors: LogContributor[]; + license?: string; + tag?: string; + institution?: { + id: string; + name: string; + }; + paramsNode: { + id: string; + title: string; + }; + paramsProject: null; + pointer: Pointer | null; + preprintProvider?: + | string + | { + url: string; + name: string; + } + | null; + addon?: string; + anonymousLink?: boolean; + file?: { + name: string; + url: string; + }; + wiki?: { + name: string; + url: string; + }; + destination?: { + materialized: string; + addon: string; + url: string; + }; + identifiers?: { + doi?: string; + ark?: string; + }; + kind?: string; + oldPage?: string; + page?: string; + pageId?: string; + path?: string; + urls?: { + view: string; + }; + preprint?: string; + source?: { + materialized: string; + addon: string; + }; + titleNew?: string; + titleOriginal?: string; + updatedFields?: Record< + string, + { + new: string; + old: string; + } + >; + value?: string; + version?: string; + githubUser?: string; + }; + embeds?: { + originalNode?: OriginalNode; + user?: User; + linkedNode?: LinkedNode; + }; +} + +interface Pointer { + category: string; + id: string; + title: string; + url: string; +} + +interface OriginalNode { + id: string; + type: string; + title: string; + description: string; + category: string; + customCitation: string | null; + dateCreated: string; + dateModified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + accessRequestsEnabled: boolean; + nodeLicense: { + copyrightHolders: string[]; + year: string | null; + }; + currentUserCanComment: boolean; + currentUserPermissions: string[]; + currentUserIsContributor: boolean; + currentUserIsContributorOrGroupMember: boolean; + wikiEnabled: boolean; + public: boolean; + subjects: { id: string; text: string }[][]; +} + +interface User { + id: string; + type: string; + fullName: string; + givenName: string; + middleNames: string; + familyName: string; + suffix: string; + dateRegistered: string; + active: boolean; + timezone: string; + locale: string; +} + +interface LinkedNode { + id: string; + type: string; + title: string; + description: string; + category: string; + customCitation: string | null; + dateCreated: string; + dateModified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + accessRequestsEnabled: boolean; + nodeLicense: { + copyrightHolders: string[]; + year: string | null; + }; + currentUserCanComment: boolean; + currentUserPermissions: string[]; + currentUserIsContributor: boolean; + currentUserIsContributorOrGroupMember: boolean; + wikiEnabled: boolean; + public: boolean; + subjects: { id: string; text: string }[][]; +} + +export interface LogContributor { + id: string; + fullName: string; + givenName: string; + middleNames: string; + familyName: string; + unregisteredName: string | null; + active: boolean; +} diff --git a/src/app/shared/models/activity-logs/index.ts b/src/app/shared/models/activity-logs/index.ts new file mode 100644 index 000000000..c84f515ab --- /dev/null +++ b/src/app/shared/models/activity-logs/index.ts @@ -0,0 +1,2 @@ +export * from './activity-logs.model'; +export * from './activity-logs-json-api.model'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 804cfabdb..a810336cc 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -1,3 +1,4 @@ +export * from './activity-logs'; export * from './addons'; export * from './brand.json-api.model'; export * from './brand.model'; diff --git a/src/app/shared/services/activity-logs/activity-log-display.service.ts b/src/app/shared/services/activity-logs/activity-log-display.service.ts new file mode 100644 index 000000000..2f37c600f --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-log-display.service.ts @@ -0,0 +1,63 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { inject, Injectable } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +import { ActivityLog } from '@shared/models'; + +import { ActivityLogFormatterService } from './activity-log-formatter.service'; + +@Injectable({ + providedIn: 'root', +}) +export class ActivityLogDisplayService { + private readonly translateService = inject(TranslateService); + private readonly sanitizer = inject(DomSanitizer); + private readonly formatter = inject(ActivityLogFormatterService); + + getActivityDisplay(log: ActivityLog): SafeHtml { + const translationKey = `activityLog.activities.${log.action}`; + const translationParams = this.buildTranslationParams(log); + + const translation = this.translateService.instant(translationKey, translationParams); + const finalTranslation = translation === translationKey ? this.formatter.buildFallbackMessage(log) : translation; + + const htmlContent = `${finalTranslation}`; + return this.sanitizer.bypassSecurityTrustHtml(htmlContent); + } + + private buildTranslationParams(log: ActivityLog): Record { + return { + addon: log.params.addon, + anonymousLink: this.formatter.buildAnonymous(log), + commentLocation: this.formatter.buildCommentLocation(log), + contributors: this.formatter.buildContributorsList(log), + destination: this.formatter.buildDestination(log), + forkedFrom: this.formatter.buildNode(log), + identifiers: this.formatter.buildIdentifiers(log), + institution: this.formatter.buildInstitution(log), + kind: log.params.kind, + license: log.params.license, + node: this.formatter.buildNode(log), + oldPage: this.formatter.buildOldPage(log), + page: this.formatter.buildPage(log), + path: this.formatter.buildPath(log), + pathType: this.formatter.buildPathType(log), + pointer: this.formatter.buildEmbeddedNode(log), + pointerCategory: this.formatter.getPointerCategory(log), + preprint: this.formatter.buildPreprint(log), + preprintProvider: this.formatter.buildPreprintProvider(log), + preprintWord: this.translateService.instant('activityLog.defaults.preprint'), + preprintWordPlural: this.translateService.instant('activityLog.defaults.preprintPlural'), + source: this.formatter.buildSource(log), + tag: this.formatter.buildTag(log), + template: this.formatter.buildEmbeddedNode(log), + titleNew: this.formatter.buildTitleNew(log), + titleOriginal: this.formatter.buildTitleOriginal(log), + updatedFields: this.formatter.buildUpdatedFields(log), + user: this.formatter.buildUser(log), + value: log.params.value, + version: this.formatter.buildVersion(log), + }; + } +} diff --git a/src/app/shared/services/activity-logs/activity-log-formatter.service.ts b/src/app/shared/services/activity-logs/activity-log-formatter.service.ts new file mode 100644 index 000000000..909e397f9 --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-log-formatter.service.ts @@ -0,0 +1,275 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { inject, Injectable } from '@angular/core'; + +import { ActivityLog } from '@shared/models'; + +import { ActivityLogUrlBuilderService } from './activity-log-url-builder.service'; + +@Injectable({ + providedIn: 'root', +}) +export class ActivityLogFormatterService { + private readonly translateService = inject(TranslateService); + private readonly urlBuilder = inject(ActivityLogUrlBuilderService); + + private readonly nodeCategories = [ + 'analysis', + 'communication', + 'data', + 'hypothesis', + 'instrumentation', + 'methods and measures', + 'procedure', + 'project', + 'software', + 'other', + ]; + + buildAnonymous(log: ActivityLog): string { + return log.params.anonymousLink + ? this.translateService.instant('activityLog.defaults.anonymousAn') + : this.translateService.instant('activityLog.defaults.anonymousA'); + } + + buildCommentLocation(log: ActivityLog): string { + const file = log.params.file; + const wiki = log.params.wiki; + + if (file) { + return this.translateService.instant('activityLog.defaults.fileOn', { + file: this.urlBuilder.buildAHrefElement(`/${file.url}`, file.name), + }); + } + + if (wiki) { + return this.translateService.instant('activityLog.defaults.wikiOn', { + wiki: this.urlBuilder.buildAHrefElement(`/${wiki.url}`, wiki.name), + }); + } + + return ''; + } + + buildContributorsList(log: ActivityLog): string { + if (!log.params.contributors || log.params.contributors.length === 0) { + return this.translateService.instant('activityLog.defaults.someUsers'); + } + + const contributors = log.params.contributors; + const maxShown = 3; + const contribList: string[] = []; + const isJustOneMore = contributors.length === maxShown + 1; + + for (let i = 0; i < contributors.length; i++) { + const contributor = contributors[i]; + let separator = ''; + + if (i < contributors.length - 1) { + separator = + i === maxShown - 1 && !isJustOneMore + ? this.translateService.instant('activityLog.defaults.contributorsAnd') + : ', '; + } + + if (i === maxShown && !isJustOneMore) { + const remainingCount = contributors.length - i; + const othersText = this.translateService.instant('activityLog.defaults.contributorsOthers'); + contribList.push(`${remainingCount}${othersText}`); + break; + } + + const displayName = contributor.active + ? `${contributor.fullName}` + : contributor.unregisteredName || contributor.fullName; + + contribList.push(`${displayName}${separator}`); + } + + return contribList.join(' '); + } + + buildDestination(log: ActivityLog): string { + if (!log.params.destination) { + return this.translateService.instant('activityLog.defaults.aNewNameLocation'); + } + + const destination = log.params.destination; + let materialized = destination.materialized; + + if (materialized.endsWith('/')) { + materialized = this.replaceSlash(destination.materialized); + return this.translateService.instant('activityLog.defaults.materialized', { + materialized, + addon: destination.addon, + }); + } else { + return this.translateService.instant('activityLog.defaults.materialized', { + materialized: this.urlBuilder.buildAHrefElement(destination.url, materialized), + addon: destination.addon, + }); + } + } + + buildIdentifiers(log: ActivityLog): string { + if (!log.params.identifiers) { + return ''; + } + + const doi = log.params.identifiers.doi; + const ark = log.params.identifiers.ark; + + if (doi && ark) { + return `doi:${doi} and ark:${ark}`; + } else if (doi) { + return `doi:${doi}`; + } else if (ark) { + return `ark:${ark}`; + } + + return ''; + } + + buildOldPage(log: ActivityLog): string { + return log.params.oldPage ? log.params.oldPage : this.translateService.instant('activityLog.defaults.pageTitle'); + } + + buildPage(log: ActivityLog): string { + if (!log.params.page) { + return this.translateService.instant('activityLog.defaults.pageTitle'); + } + + return this.urlBuilder.buildPageUrl(log, log.params.page); + } + + buildPath(log: ActivityLog): string { + if (!log.params.path) { + return this.translateService.instant('activityLog.defaults.aFile'); + } + + const path = this.replaceSlash(log.params.path); + return this.urlBuilder.buildFileUrl(log, path); + } + + buildPathType(log: ActivityLog): string { + if (!log.params.path) { + return ''; + } + + return log.params.path[0] === '/' + ? this.translateService.instant('activityLog.defaults.folder') + : this.translateService.instant('activityLog.defaults.file'); + } + + buildSource(log: ActivityLog): string { + if (!log.params.source) { + return this.translateService.instant('activityLog.defaults.aNameLocation'); + } + + const source = log.params.source; + const materialized = this.replaceSlash(source.materialized); + + return this.translateService.instant('activityLog.defaults.materialized', { + materialized, + addon: source.addon, + }); + } + + buildTitleNew(log: ActivityLog): string { + const url = this.urlBuilder.buildTitleUrl(log, log.params.titleNew); + return url || this.translateService.instant('activityLog.defaults.aTitle'); + } + + buildTitleOriginal(log: ActivityLog): string { + const url = this.urlBuilder.buildTitleUrl(log, log.params.titleOriginal); + return url || this.translateService.instant('activityLog.defaults.aTitle'); + } + + buildUpdatedFields(log: ActivityLog): string { + if (!log.params.updatedFields) { + return this.translateService.instant('activityLog.defaults.field'); + } + + const updatedFieldsParam = log.params.updatedFields; + const updatedField = Object.keys(updatedFieldsParam)[0]; + + if (updatedField === 'category') { + const newValue = updatedFieldsParam[updatedField].new; + const newText = this.nodeCategories.includes(newValue) + ? this.translateService.instant(`nodeCategories.${newValue}`) + : this.translateService.instant('activityLog.defaults.uncategorized'); + + return this.translateService.instant('activityLog.defaults.updatedFields', { + old: updatedField, + new: newText, + }); + } + + return this.translateService.instant('activityLog.defaults.updatedFields', { + old: updatedField, + new: updatedFieldsParam[updatedField].new, + }); + } + + buildVersion(log: ActivityLog): string { + return log.params.version || '#'; + } + + getPointerCategory(log: ActivityLog): string { + const linkedNode = log.embeds?.linkedNode; + return linkedNode?.category || ''; + } + + buildUser(log: ActivityLog): string { + const userUrl = this.urlBuilder.buildUserUrl(log); + return userUrl || this.translateService.instant('activityLog.defaults.aUser'); + } + + buildNode(log: ActivityLog): string { + return this.urlBuilder.buildNodeUrl(log); + } + + buildEmbeddedNode(log: ActivityLog): string { + const url = this.urlBuilder.buildEmbeddedUrl(log); + return url || this.translateService.instant('activityLog.defaults.aTitle'); + } + + buildInstitution(log: ActivityLog): string { + return this.urlBuilder.buildInstitutionUrl(log); + } + + buildTag(log: ActivityLog): string { + return this.urlBuilder.buildTagUrl(log); + } + + buildPreprint(log: ActivityLog): string { + const preprintWord = this.translateService.instant('activityLog.defaults.preprint'); + return this.urlBuilder.buildPreprintUrl(log, preprintWord); + } + + buildPreprintProvider(log: ActivityLog): string { + return this.urlBuilder.buildPreprintProviderUrl(log); + } + + buildFallbackMessage(log: ActivityLog): string { + const user = this.buildUser(log); + const node = this.buildNode(log); + const action = log.action.replace(/_/g, ' '); + + if (node) { + return this.translateService.instant('activityLog.defaults.fallbackWithNode', { + user, + action, + node, + }); + } + return this.translateService.instant('activityLog.defaults.fallbackWithoutNode', { + user, + action, + }); + } + + private replaceSlash(path: string): string { + return path.replace(/^\/|\/$/g, ''); + } +} diff --git a/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts new file mode 100644 index 000000000..5148a0acd --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@angular/core'; + +import { ActivityLog } from '@shared/models'; + +@Injectable({ + providedIn: 'root', +}) +export class ActivityLogUrlBuilderService { + buildAHrefElement(url: string | undefined, value: string): string { + const safeUrl = url || ''; + const relativeUrl = this.toRelativeUrl(safeUrl); + return `${value}`; + } + + buildUserUrl(log: ActivityLog): string { + const user = log.embeds?.user; + const githubUser = log.params.githubUser; + + if (user?.id) { + return this.buildAHrefElement(`/${user.id}`, user.fullName); + } else if (user?.fullName) { + return user.fullName; + } else if (githubUser) { + return githubUser; + } + + return ''; + } + + buildNodeUrl(log: ActivityLog): string { + if (!log.params.paramsNode) { + return ''; + } + + return this.buildAHrefElement(`project/${log.params.paramsNode.id}`, log.params.paramsNode.title); + } + + buildInstitutionUrl(log: ActivityLog): string { + if (!log.params.institution) { + return ''; + } + + return this.buildAHrefElement(`/institutions/${log.params.institution.id}`, log.params.institution.name); + } + + buildTagUrl(log: ActivityLog): string { + if (!log.params.tag) { + return ''; + } + + return this.buildAHrefElement(`/search?search=%22${log.params.tag}%22`, log.params.tag); + } + + buildPreprintUrl(log: ActivityLog, preprintWord: string): string { + if (!log.params.preprint) { + return ''; + } + + return this.buildAHrefElement(`preprints/${log.params.preprint}`, preprintWord); + } + + buildPreprintProviderUrl(log: ActivityLog): string { + if (!log.params.preprintProvider) { + return ''; + } + + if (typeof log.params.preprintProvider === 'string') { + return log.params.preprintProvider; + } + + return this.buildAHrefElement( + `preprints/overview/${log.params.preprintProvider.url}`, + log.params.preprintProvider.name + ); + } + + buildTitleUrl(log: ActivityLog, title: string | undefined): string { + const originalNode = log.embeds?.originalNode; + if (originalNode?.id && title) { + return this.buildAHrefElement(`/${originalNode.id}`, title); + } + return ''; + } + + buildEmbeddedUrl(log: ActivityLog): string { + const linkedNode = log.embeds?.linkedNode; + const originalNode = log.embeds?.originalNode; + + if (linkedNode?.id) { + return this.buildAHrefElement(`project/${linkedNode.id}`, linkedNode.title); + } else if (originalNode?.id) { + return this.buildAHrefElement(`project/${originalNode.id}`, originalNode.title); + } + return ''; + } + + buildFileUrl(log: ActivityLog, path: string): string { + const acceptableLinkedItems = [ + 'osf_storage_file_added', + 'osf_storage_file_updated', + 'file_tag_added', + 'file_tag_removed', + 'github_file_added', + 'github_file_updated', + 'box_file_added', + 'box_file_updated', + 'dropbox_file_added', + 'dropbox_file_updated', + 's3_file_added', + 's3_file_updated', + 'figshare_file_added', + 'checked_in', + 'checked_out', + 'file_metadata_updated', + ]; + + if (acceptableLinkedItems.includes(log.action) && log.params.urls?.view) { + return this.buildAHrefElement(log.params.urls.view, path); + } + + return path; + } + + buildPageUrl(log: ActivityLog, page: string): string { + const acceptableLinkedItems = ['wiki_updated', 'wiki_renamed']; + const projectId = log.embeds?.originalNode?.id; + if (acceptableLinkedItems.includes(log.action) && log.params.pageId && projectId) { + return this.buildAHrefElement(`project/${projectId}/wiki/?wiki=${log.params.pageId}`, page); + } + + return page; + } + + private toRelativeUrl(url: string): string { + if (!url) return ''; + + try { + const parser = document.createElement('a'); + parser.href = url; + + if (window.location.hostname === parser.hostname) { + return parser.pathname + parser.search + parser.hash; + } + return url; + } catch { + return url; + } + } +} diff --git a/src/app/shared/services/activity-logs/activity-logs.service.ts b/src/app/shared/services/activity-logs/activity-logs.service.ts new file mode 100644 index 000000000..2f7098c7e --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -0,0 +1,30 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { ActivityLogsMapper } from '@shared/mappers/activity-logs.mapper'; +import { ActivityLog, ActivityLogJsonApi, PaginatedData, ResponseJsonApi } from '@shared/models'; +import { JsonApiService } from '@shared/services/json-api.service'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ActivityLogsService { + private jsonApiService = inject(JsonApiService); + + fetchLogs(projectId: string, page = '1', pageSize: string): Observable> { + const url = `${environment.apiUrl}/nodes/${projectId}/logs/`; + const params: Record = { + 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'], + page, + 'page[size]': pageSize, + }; + + return this.jsonApiService + .get>(url, params) + .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); + } +} diff --git a/src/app/shared/services/activity-logs/index.ts b/src/app/shared/services/activity-logs/index.ts new file mode 100644 index 000000000..af659c147 --- /dev/null +++ b/src/app/shared/services/activity-logs/index.ts @@ -0,0 +1,4 @@ +export { ActivityLogDisplayService } from './activity-log-display.service'; +export { ActivityLogFormatterService } from './activity-log-formatter.service'; +export { ActivityLogUrlBuilderService } from './activity-log-url-builder.service'; +export { ActivityLogsService } from './activity-logs.service'; diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index 0c8f20611..6216fa276 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -33,7 +33,7 @@ export class BookmarksService { const payload = { data: [ { - type: 'linked_nodes', + type: 'nodes', id: projectId, }, ], @@ -47,7 +47,7 @@ export class BookmarksService { const payload = { data: [ { - type: 'linked_nodes', + type: 'nodes', id: projectId, }, ], @@ -61,7 +61,7 @@ export class BookmarksService { const payload = { data: [ { - type: 'linked_registrations', + type: 'registrations', id: registryId, }, ], @@ -75,7 +75,7 @@ export class BookmarksService { const payload = { data: [ { - type: 'linked_registrations', + type: 'registrations', id: registryId, }, ], diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 41ca37cad..1d71fa2f4 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -1,3 +1,4 @@ +export * from './activity-logs'; export * from './addons'; export { BookmarksService } from './bookmarks.service'; export { BrandService } from './brand.service'; diff --git a/src/app/shared/stores/activity-logs/activity-logs.actions.ts b/src/app/shared/stores/activity-logs/activity-logs.actions.ts new file mode 100644 index 000000000..50d9c2e0b --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.actions.ts @@ -0,0 +1,13 @@ +export class GetActivityLogs { + static readonly type = '[ActivityLogs] Get Activity Logs'; + + constructor( + public projectId: string, + public page = '1', + public pageSize: string + ) {} +} + +export class ClearActivityLogsStore { + static readonly type = '[ActivityLogs] Clear Store'; +} diff --git a/src/app/shared/stores/activity-logs/activity-logs.model.ts b/src/app/shared/stores/activity-logs/activity-logs.model.ts new file mode 100644 index 000000000..23c480ff0 --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.model.ts @@ -0,0 +1,14 @@ +import { ActivityLog, AsyncStateWithTotalCount } from '@shared/models'; + +export interface ActivityLogsStateModel { + activityLogs: AsyncStateWithTotalCount; +} + +export const ACTIVITY_LOGS_STATE_DEFAULT: ActivityLogsStateModel = { + activityLogs: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, +}; diff --git a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts new file mode 100644 index 000000000..61e746506 --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts @@ -0,0 +1,23 @@ +import { Selector } from '@ngxs/store'; + +import { ActivityLog } from '@shared/models'; + +import { ActivityLogsStateModel } from './activity-logs.model'; +import { ActivityLogsState } from './activity-logs.state'; + +export class ActivityLogsSelectors { + @Selector([ActivityLogsState]) + static getActivityLogs(state: ActivityLogsStateModel): ActivityLog[] { + return state.activityLogs.data; + } + + @Selector([ActivityLogsState]) + static getActivityLogsTotalCount(state: ActivityLogsStateModel): number { + return state.activityLogs.totalCount; + } + + @Selector([ActivityLogsState]) + static getActivityLogsLoading(state: ActivityLogsStateModel): boolean { + return state.activityLogs.isLoading; + } +} diff --git a/src/app/shared/stores/activity-logs/activity-logs.state.ts b/src/app/shared/stores/activity-logs/activity-logs.state.ts new file mode 100644 index 000000000..0ac91e5c6 --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -0,0 +1,49 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { tap } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { ActivityLogsService } from '@shared/services'; + +import { ClearActivityLogsStore, GetActivityLogs } from './activity-logs.actions'; +import { ACTIVITY_LOGS_STATE_DEFAULT, ActivityLogsStateModel } from './activity-logs.model'; + +@State({ + name: 'activityLogs', + defaults: ACTIVITY_LOGS_STATE_DEFAULT, +}) +@Injectable() +export class ActivityLogsState { + private readonly activityLogsService = inject(ActivityLogsService); + + @Action(GetActivityLogs) + getActivityLogs(ctx: StateContext, action: GetActivityLogs) { + ctx.patchState({ + activityLogs: { + data: [], + isLoading: true, + error: null, + totalCount: 0, + }, + }); + + return this.activityLogsService.fetchLogs(action.projectId, action.page, action.pageSize).pipe( + tap((res) => { + ctx.patchState({ + activityLogs: { + data: res.data, + isLoading: false, + error: null, + totalCount: res.totalCount, + }, + }); + }) + ); + } + + @Action(ClearActivityLogsStore) + clearActivityLogsStore(ctx: StateContext) { + ctx.setState(ACTIVITY_LOGS_STATE_DEFAULT); + } +} diff --git a/src/app/shared/stores/activity-logs/index.ts b/src/app/shared/stores/activity-logs/index.ts new file mode 100644 index 000000000..399b3f9c8 --- /dev/null +++ b/src/app/shared/stores/activity-logs/index.ts @@ -0,0 +1,4 @@ +export * from './activity-logs.actions'; +export * from './activity-logs.model'; +export * from './activity-logs.selectors'; +export * from './activity-logs.state'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 36af20d73..ffedecfff 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -566,7 +566,14 @@ "noLinkedProjectsMessage": "Link your project." }, "recentActivity": { - "title": "Recent Activity" + "title": "Recent Activity", + "noActivity": "No recent activity" + }, + "collectionsModeration": { + "pending": "Pending entry into\u00A0", + "accepted": "Included in\u00A0", + "removed": "Removed from\u00A0", + "rejected": "Rejected from\u00A0" }, "metadata": { "title": "Metadata", @@ -2518,5 +2525,153 @@ "noCurrentResource": "No current resource.", "doiValidation": "Please enter a valid DOI in the format: 10.xxxx/xxxxx" } + }, + "activityLog": { + "defaults": { + "preprint": "preprint", + "preprintPlural": "preprints", + "anonymousA": "a", + "anonymousAn": "an", + "someUsers": "some users", + "contributorsAnd": " and ", + "contributorsOthers": " others", + "aNewNameLocation": "a new name/location", + "materialized": "{{materialized}} {{addon}}", + "aTitle": "a title", + "aNameLocation": "a name/location", + "pageTitle": "page title", + "aFile": "a file", + "folder": "folder", + "file": "file", + "aUser": "A user", + "fileOn": "file {{file}}", + "wikiOn": "wiki {{wiki}}", + "field": "field", + "updatedFields": "{{old}} to {{new}}", + "uncategorized": "Uncategorized", + "fallbackWithNode": "{{user}} performed action \"{{action}}\" on {{node}}", + "fallbackWithoutNode": "{{user}} performed action \"{{action}}\"" + }, + "activities": { + "addon_added": "{{user}} added addon {{addon}} to {{node}}", + "addon_file_copied": "{{user}} copied {{source}} to {{destination}} in {{node}}", + "addon_file_moved": "{{user}} moved {{source}} to {{destination}} in {{node}}", + "addon_file_renamed": "{{user}} renamed {{source}} to {{destination}} in {{node}}", + "addon_removed": "{{user}} removed addon {{addon}} from {{node}}", + "affiliated_institution_added": "{{user}} added {{institution}} affiliation to {{node}}", + "affiliated_institution_removed": "{{user}} removed {{institution}} affiliation from {{node}}", + "article_doi_updated": "{{user}} changed the article_doi of {{node}}", + "category_updated": "{{user}} changed the category of {{node}}", + "checked_in": "{{user}} checked in {{kind}} {{path}} to {{node}}", + "checked_out": "{{user}} checked out {{kind}} {{path}} from {{node}}", + "coi_statement_updated": "{{user}} changed the conflict of interest statement for {{preprint}}.", + "comment_added": "{{user}} added a comment {{commentLocation}} in {{node}}", + "comment_removed": "{{user}} deleted a comment {{commentLocation}} in {{node}}", + "comment_restored": "{{user}} restored a comment {{commentLocation}} in {{node}}", + "comment_updated": "{{user}} updated a comment {{commentLocation}} in {{node}}", + "contributor_added": "{{user}} added {{contributors}} as contributor(s) to {{node}}", + "contributor_removed": "{{user}} removed {{contributors}} as contributor(s) from {{node}}", + "contributors_reordered": "{{user}} reordered contributors for {{node}}", + "created_from": "{{user}} created {{node}} based on {{template}}", + "custom_citation_added": "{{user}} created a custom citation for {{node}}", + "custom_citation_edited": "{{user}} edited a custom citation for {{node}}", + "custom_citation_removed": "{{user}} removed a custom citation from {{node}}", + "data_links_updated": "{{user}} has updated their data links", + "edit_description": "{{user}} edited description of {{node}}", + "edit_title": "{{user}} changed the title from {{titleOriginal}} to {{titleNew}}", + "embargo_approved": "{{user}} approved embargoed registration of {{node}}", + "embargo_approved_no_user": "Embargo of registration of {{node}} approved", + "embargo_cancelled": "{{user}} cancelled embargoed registration of {{node}}", + "embargo_completed": "{{user}} completed embargo of {{node}}", + "embargo_completed_no_user": "Embargo for {{node}} completed", + "embargo_initiated": "{{user}} initiated an embargoed registration of {{node}}", + "embargo_terminated": "Embargo for {{node}} ended", + "external_ids_added": "{{user}} created external identifier(s) {{identifiers}} on {{node}}", + "external_registration_created": "A registration of {{node}} was created on an external registry.", + "external_registration_imported": "A registration of {{node}} was imported to OSF from an external registry.", + "file_added": "{{user}} added file {{path}} to {{node}}", + "file_metadata_updated": "{{user}} updated file metadata for {{path}}", + "file_removed": "{{user}} removed {{pathType}} {{path}} from {{node}}", + "file_restored": "{{user}} restored file {{path}} from {{node}}", + "file_tag_added": "{{user}} added tag {{tag}} to {{path}} in OSF Storage in {{node}}", + "file_tag_removed": "{{user}} removed tag {{tag}} from {{path}} in OSF Storage in {{node}}", + "file_updated": "{{user}} updated file in {{node}}", + "folder_created": "{{user}} created a folder in {{node}}", + "group_added": "{{user}} added {{group}} to {{node}}", + "group_removed": "{{user}} removed {{group}} from {{node}}", + "group_updated": "{{user}} changed {{group}} permissions to {{node}}", + "guid_metadata_updated": "{{user}} updated metadata for {{node}}", + "has_coi_updated": "{{user}} changed the conflict of interest statement availability for {{preprint}}.", + "has_data_links_updated": "{{user}} has updated the has links to data field to {{value}}", + "has_prereg_links_updated": "{{user}} has updated their preregistration data link availability to {{value}}", + "license_changed": "{{user}} updated the license of {{node}} to {{license}}", + "made_contributor_invisible": "{{user}} made bibliographic contributor {{contributors}} a non-bibliographic contributor on {{node}}", + "made_contributor_visible": "{{user}} made non-bibliographic contributor {{contributors}} a bibliographic contributor on {{node}}", + "made_private": "{{user}} made {{node}} private", + "made_public": "{{user}} made {{node}} public", + "made_public_no_user": "{{node}} made public", + "made_wiki_private": "{{user}} made the wiki of {{node}} privately editable", + "made_wiki_public": "{{user}} made the wiki of {{node}} publicly editable", + "migrated_quickfiles": "{{user}} had their QuickFiles migrated into {{node}}", + "node_access_requests_disabled": "{{user}} disabled access requests for {{node}}", + "node_access_requests_enabled": "{{user}} enabled access requests for {{node}}", + "node_created": "{{user}} created {{node}}", + "node_forked": "{{user}} created fork from {{forkedFrom}}", + "node_removed": "{{user}} removed {{node}}", + "osf_storage_file_added": "{{user}} added file {{path}} to OSF Storage in {{node}}", + "osf_storage_file_removed": "{{user}} removed {{pathType}} {{path}} from OSF Storage in {{node}}", + "osf_storage_file_updated": "{{user}} updated file {{path}} in OSF Storage in {{node}}", + "osf_storage_folder_created": "{{user}} created folder {{path}} in OSF Storage in {{node}}", + "permissions_updated": "{{user}} changed permissions for {{node}}", + "pointer_created": "{{user}} created a link to {{pointerCategory}} {{pointer}}", + "pointer_forked": "{{user}} forked a link to {{pointerCategory}} {{pointer}}", + "pointer_removed": "{{user}} removed a link to {{pointerCategory}} {{pointer}}", + "preprint_file_updated": "{{user}} updated the primary file of this {{preprint}} on {{preprintProvider}} {{preprintWordPlural}}", + "preprint_initiated": "{{user}} made {{node}} a {{preprint}} on {{preprintProvider}} {{preprintWordPlural}}", + "preprint_license_updated": "{{user}} updated the license of this {{preprint}} on {{preprintProvider}} {{preprintWordPlural}} {{license}}", + "prereg_links_info_updated": "{{user}} has updated their preregistration links to {{value}}", + "prereg_links_updated": "{{user}} has updated their preregistration data links", + "prereg_registration_initiated": "{{user}} submitted for review to the Preregistration Challenge a registration of {{node}}", + "project_created": "{{user}} created {{node}}", + "project_created_from_draft_reg": "{{node}} was created from a draft registration", + "project_deleted": "{{user}} deleted {{node}}", + "project_registered": "{{user}} registered {{node}}", + "project_registered_no_user": "{{node}} registered", + "registration_approved": "{{user}} approved a registration of {{node}}", + "registration_approved_no_user": "Registration of {{node}} was approved", + "registration_cancelled": "{{user}} cancelled a registration of {{node}}", + "registration_initiated": "{{user}} initiated a registration of {{node}}", + "resource_identifier_added": "{{user}} has added a Resource to Registration {{node}}", + "resource_identifier_removed": "{{user}} has removed a Resource to Registration {{node}}", + "resource_identifier_updated": "{{user}} has updated a Resource to Registration {{node}}", + "retraction_approved": "{{user}} approved a withdrawal of a registration of {{node}}", + "retraction_approved_no_user": "A withdrawal of a registration of {{node}} was approved", + "retraction_cancelled": "{{user}} cancelled withdrawal of a registration of {{node}}", + "retraction_initiated": "{{user}} initiated withdrawal of a registration of {{node}}", + "retraction_initiated_no_user": "A withdrawal of a registration of {{node}} was proposed", + "subjects_updated": "{{user}} updated the subjects on {{node}}", + "tag_added": "{{user}} added tag {{tag}} to {{node}}", + "tag_removed": "{{user}} removed tag {{tag}} from {{node}}", + "updated_fields": "{{user}} changed the {{updatedFields}} for {{node}}", + "view_only_link_added": "{{user}} created {{anonymousLink}} view-only link to {{node}}", + "view_only_link_removed": "{{user}} removed {{anonymousLink}} view-only link to {{node}}", + "why_no_data_updated": "{{user}} has updated their data statement", + "why_no_prereg_updated": "{{user}} has updated their preregistration data availability statement", + "wiki_deleted": "{{user}} deleted wiki page {{page}} of {{node}}", + "wiki_renamed": "{{user}} renamed wiki page {{oldPage}} to {{page}} of {{node}}", + "wiki_updated": "{{user}} updated wiki page {{page}} to version {{version}} of {{node}}" + } + }, + "nodeCategories": { + "analysis": "Analysis", + "communication": "Communication", + "data": "Data", + "hypothesis": "Hypothesis", + "instrumentation": "Instrumentation", + "methods and measures": "Methods and Measures", + "procedure": "Procedure", + "project": "Project", + "software": "Software", + "other": "Other" } -} +} \ No newline at end of file