diff --git a/src/app/item-page/sedici-item-certificate/sedici-item-certificate.html b/src/app/item-page/sedici-item-certificate/sedici-item-certificate.html index 8d3122608a7..0633045a3b5 100644 --- a/src/app/item-page/sedici-item-certificate/sedici-item-certificate.html +++ b/src/app/item-page/sedici-item-certificate/sedici-item-certificate.html @@ -4,174 +4,186 @@
- + }
CERTIFICADO DE DEPÓSITO

Por medio del presente certificado, SEDICI confirma el depósito de esta obra - en el repositorio institucional de la UNLP

-
- + en el repositorio institucional de la UNLP

-

- . - {{ object.firstMetadataValue('sedici.title.subtitle') }} - -

-
-

-
- - -
-
- + +
+

+ @if (object.firstMetadataValue('sedici.title.subtitle') && object.firstMetadataValue('dc.title.alternative')) { + . + {{ object.firstMetadataValue('sedici.title.subtitle') }} + + } +

+
+

+ @if (!object.firstMetadataValue('dc.title.alternative')) { +
+ + +
+ } + @if (object.firstMetadataValue('dc.title.alternative')) { +
+ @for (titleAlternative of object.allMetadata('dc.title.alternative'); track titleAlternative) { +
({{titleAlternative.language}})
-
-

-
-
-
- @if (object.allMetadata(['sedici.creator.*', 'sedici.contributor.compiler', 'sedici.contributor.editor']).length - === 0) { - {{ 'sedici.item.page.unknown.author' | translate }} - } - - - - - - - - - - - - - - -
- -
-
- - - - - - - - + }
-
+ } + +
+
+
+ @if (object.allMetadata(['sedici.creator.*', 'sedici.contributor.compiler', 'sedici.contributor.editor']).length + === 0) { + {{ 'sedici.item.page.unknown.author' | translate }} + } + + + + + + + + + + + + + + +
+ +
+
+ @for (idValue of itemIdentifiers; track idValue) { + + + } + + + +
+ + -
-
-
-
- -
- - +

Puede acceder a la obra a través del siguiente enlace o a través del qr a continuación:

+
+
+
+
+
+

Este documento fue generado el {{ fechaActual | date: 'dd/MM/yyyy' }} a las {{ fechaActual | + date: 'HH:mm' }} por el usuario {{(user$ | async)?.email}}

+
\ No newline at end of file diff --git a/src/app/item-page/sedici-item-certificate/sedici-item-certificate.ts b/src/app/item-page/sedici-item-certificate/sedici-item-certificate.ts index 46177fabae3..867298f9369 100644 --- a/src/app/item-page/sedici-item-certificate/sedici-item-certificate.ts +++ b/src/app/item-page/sedici-item-certificate/sedici-item-certificate.ts @@ -1,10 +1,4 @@ -import { - AsyncPipe, - NgIf, - NgFor, - - NgClass, -} from '@angular/common'; +import { AsyncPipe, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -43,27 +37,20 @@ import { QRCodeComponent } from 'angularx-qrcode'; styleUrls: ['./sedici-item-certificate.scss'], standalone: true, imports: [ - NgIf, - NgFor, - NgClass, DsoEditMenuComponent, GenericItemPageFieldComponent, ItemPageUriFieldComponent, - AsyncPipe, TranslateModule, - BadgeMetadataValuesComponent, - SediciContextBadgeComponent, SediciTruncatableGenericItemPageFieldComponent, SediciContextComponent, NgbModule, - CommonModule, - QRCodeComponent, - ], // Solo los necesarios si el HTML nuevo necesita alguno en particular + QRCodeComponent +], // Solo los necesarios si el HTML nuevo necesita alguno en particular }) export class SediciCertificate extends UntypedItemComponent implements OnInit { diff --git a/src/app/item-page/sedici-print-certificate/sedici-print-certificate.html b/src/app/item-page/sedici-print-certificate/sedici-print-certificate.html index 852a06daa3c..dde8985f55b 100644 --- a/src/app/item-page/sedici-print-certificate/sedici-print-certificate.html +++ b/src/app/item-page/sedici-print-certificate/sedici-print-certificate.html @@ -1,11 +1,21 @@
-
-
- - - + @if (itemRD?.hasSucceeded) { +
+ @if (itemRD?.payload; as item) { +
+ @if (!item.isWithdrawn || (isAdmin$|async)) { + + } + + +
+ }
-
- - + } + @if (itemRD?.hasFailed) { + + } + @if (itemRD?.isLoading) { + + }
diff --git a/src/app/item-page/sedici-print-certificate/sedici-print-certificate.ts b/src/app/item-page/sedici-print-certificate/sedici-print-certificate.ts index b9f7853e73a..1790a14c13b 100644 --- a/src/app/item-page/sedici-print-certificate/sedici-print-certificate.ts +++ b/src/app/item-page/sedici-print-certificate/sedici-print-certificate.ts @@ -1,9 +1,4 @@ -import { - AsyncPipe, - isPlatformServer, - NgIf, - -} from '@angular/common'; +import { AsyncPipe, isPlatformServer } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -75,18 +70,13 @@ import { SediciCertificate } from '../sedici-item-certificate/sedici-item-certif standalone: true, imports: [ VarDirective, - - ItemVersionsComponent, ErrorComponent, ThemedLoadingComponent, TranslateModule, AsyncPipe, - NgIf, - - - SediciCertificate, - ], + SediciCertificate +], }) export class PrintableCertificate implements OnInit, OnDestroy { diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 20aa8282113..9e0b193ec25 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -86,6 +86,7 @@ export class RootComponent implements OnInit { maxMobileWidth = WidthCategory.SM; public isHomePage$: BehaviorSubject = new BehaviorSubject(false); + public isSubmissionPage$: BehaviorSubject = new BehaviorSubject(false); private routerSubscription: Subscription; /** @@ -146,15 +147,18 @@ export class RootComponent implements OnInit { const currentUrl = this.router.url; this.isHomePage$.next(this.isHomeUrl(currentUrl)); + this.isSubmissionPage$.next(this.isSubmissionUrl(currentUrl)); this.routerSubscription = this.router.events.pipe( filter(event => event instanceof NavigationEnd), map((event: NavigationEnd) => { const isHome = this.isHomeUrl(event.urlAfterRedirects); - return isHome; + const isSubmission = this.isSubmissionUrl(event.urlAfterRedirects); + return { isHome, isSubmission }; }) - ).subscribe(isHome => { + ).subscribe(({ isHome, isSubmission }) => { this.isHomePage$.next(isHome); + this.isSubmissionPage$.next(isSubmission); }); } @@ -171,6 +175,10 @@ export class RootComponent implements OnInit { url.startsWith('/home?'); } + private isSubmissionUrl(url: string): boolean { + return url.startsWith('/workspaceitems') && url.endsWith('/edit'); + } + skipToMainContent() { const mainContent = document.getElementById('main-content'); if (mainContent) { diff --git a/src/app/shared/edit-item-menu/edit-item-menu.component.html b/src/app/shared/edit-item-menu/edit-item-menu.component.html index 0a26d7a274b..c5b69ffe6e2 100644 --- a/src/app/shared/edit-item-menu/edit-item-menu.component.html +++ b/src/app/shared/edit-item-menu/edit-item-menu.component.html @@ -1,10 +1,12 @@ -
- -
- - +@if ((isAuthenticated | async) && contextMenuObject) { +
+ +
+ + +
-
+} diff --git a/src/app/shared/edit-item-menu/edit-item-menu.component.ts b/src/app/shared/edit-item-menu/edit-item-menu.component.ts index 841638a9b11..e69a7ce94b0 100644 --- a/src/app/shared/edit-item-menu/edit-item-menu.component.ts +++ b/src/app/shared/edit-item-menu/edit-item-menu.component.ts @@ -1,9 +1,5 @@ import { Component, Inject, OnDestroy, OnInit, Input } from '@angular/core'; -import { - AsyncPipe, - - NgIf, -} from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { Router, RouterModule, @@ -40,7 +36,7 @@ import { AuthService } from 'src/app/core/auth/auth.service'; styleUrls: ['./edit-item-menu.component.scss'], templateUrl: './edit-item-menu.component.html', standalone: true, - imports: [NgIf, TranslateModule, RouterModule, AsyncPipe, NgbDropdownModule], + imports: [TranslateModule, RouterModule, AsyncPipe, NgbDropdownModule], }) export class EditItemMenuComponent implements OnInit, OnDestroy { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 2511a00ef9a..06379eb6488 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -43,7 +43,7 @@
@if (model.languageCodes && model.languageCodes.length > 0) { -
+
+
+ } + + @if (showDynamicInputs) { +
+ } +
+ + +
+ +
+ + +
+
+ } +
\ No newline at end of file diff --git a/src/app/submission/form/pdf-viewer.component.scss b/src/app/submission/form/pdf-viewer.component.scss new file mode 100644 index 00000000000..6b098bbd93d --- /dev/null +++ b/src/app/submission/form/pdf-viewer.component.scss @@ -0,0 +1,218 @@ +.colapsed-viewer { + height: 60vh; + + @media (max-height: 800px) { + height: 45vh; + } +} + +.expanded-viewer { + height: 85vh; +} + +.selection-menu { + border: 1px solid #ddd; + border-radius: 5px; + padding: 10px; + background-color: #f9f9f9; + margin-bottom: 5px; + + .section { + margin-bottom: 10px; + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + } + + label { + font-weight: bold; + margin-bottom: 0px; + } + + .filter-toggle-btn { + padding: 10px 15px; + border: none; + border-radius: 5px; + cursor: pointer; + transition: all 0.3s ease; + color: white; + + &.active { + background-color: #28a745; // Verde para activado + + &:hover { + background-color: #259640; + } + } + + &.inactive { + background-color: #6c757d; // Gris para desactivado + + &:hover { + background-color: #4f565c; + } + } + } + + textarea { + width: 100%; + max-height: 10vh; + resize: vertical; + padding: 10px; + box-sizing: border-box; + font-size: 14px; + } + } + + .selection-submenu { + margin-bottom: 5px; + + .metadata-filter-row { + display: flex; + align-items: center; + gap: 10px; + + .metadata-field { + flex: 0 0 34%; + + #metadata-field-search { + width: 100%; + height: 42px; + padding: 6px 10px; + border: 1px solid #ced4da; + border-radius: 4px; + } + } + + .filter-buttons { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + justify-content: end; + + .filter-btn { + min-width: 42px; + height: 42px; + padding: 5px 10px; + font-size: 14px; + border: 1px solid #c4c8cc; + border-radius: 5px; + background-color: #e9e9e9; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .filter-btn:hover { + background-color: #e0e0e0; + } + + .dropdown { + .dropdown-menu { + display: none; + } + + .dropdown-menu.show { + display: flex; + min-width: max-content; + border: 1px solid #c4c8cc; + border-radius: 5px; + padding: 8px; + gap: 8px; + background-color: #f9f9f9; + } + + .dropdown-item.filter-btn { + margin: 0; + line-height: 1; + text-decoration: none; + } + } + } + } + } + + .action-buttons { + display: flex; + justify-content: space-between; + gap: 10px; + + .filter-btn { + padding: 10px 15px; + font-size: 14px; + border: none; + border-radius: 5px; + background-color: #e9e9e9; + } + + .filter-btn:hover { + background-color: #e0e0e0; + } + + .btn { + flex: 1; + padding: 10px 15px; + font-size: 14px; + border: none; + border-radius: 5px; + + &.btn-primary { + background-color: #007bff; + color: #fff; + } + + &.btn-secondary { + background-color: #6c757d; + color: #fff; + } + } + } + + .inputs-grid { + display: grid; + grid-template-rows: repeat(4, auto); // Define un máximo de 4 filas + grid-auto-flow: column; + gap: 5px; + font-size: .75rem; + } +} + +:host { + ::ng-deep { + .dynamic-input { + width: 90%; + + &:focus-visible { + outline: 2px solid lightblue; + background: #f0f8ff; + border: none; + } + } + + .people-valid { border: 2px solid green; } + .people-warning { border: 2px solid yellow; } + .people-invalid { border: 2px solid red; } + } +} + +::ng-deep { + .pdf-viewer-layout-wrapper { + display: flex; + align-items: center; + gap: 8px; + } + + .pdf-viewer-layout-element { + flex: 1 1 auto; + min-width: 0; + } + + .status-modified { + border: 2px solid green !important; + transition: border 0.2s ease-in-out; + } +} \ No newline at end of file diff --git a/src/app/submission/form/pdf-viewer.component.ts b/src/app/submission/form/pdf-viewer.component.ts new file mode 100644 index 00000000000..405eb6b2ec7 --- /dev/null +++ b/src/app/submission/form/pdf-viewer.component.ts @@ -0,0 +1,1140 @@ +import { Component, Input, ViewChild, ChangeDetectorRef, NgZone, ComponentRef, AfterViewInit, ViewContainerRef } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { PdfJsViewerModule } from "ng2-pdfjs-viewer"; +import { NgbDropdownModule, NgbTypeaheadModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; +import { Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; + +import { filterTransformer } from '../../../../zfilterTransformer/filterTransformer.js'; +import { DynamicButtonDropdownComponent } from './dynamic-button-dropdown.component'; +import { ShortcutsButtonsComponent } from './shortcuts-buttons.component'; +import { SectionFormOperationsService } from '../sections/form/section-form-operations.service'; +import { JsonPatchOperationPathCombiner } from '../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { DateSplitter, VolumeIssueSplitter } from '../utils/content-splitters'; +import { MetadataConfig } from '../models/metadata-config.model'; +import { FilterInfo, FilterConfig } from '../models/filter-config.model'; + +@Component({ + selector: 'app-pdf-viewer', + templateUrl: './pdf-viewer.component.html', + styleUrls: ['./pdf-viewer.component.scss'], + standalone: true, + imports: [ + NgClass, + FormsModule, + PdfJsViewerModule, + NgbDropdownModule, + NgbTypeaheadModule, + DynamicButtonDropdownComponent, + ShortcutsButtonsComponent +], +}) + +export class PdfViewerComponent implements AfterViewInit { + @Input() pdfUrl: string; + @ViewChild('pdfViewerOnDemand') pdfViewerOnDemand; + + // Propiedades del visor + iframe; + container; + pdfIsLoading = true; + private buttonComponents: ComponentRef[] = []; + + // Estado de la selección de texto + selectedText: string = ''; + isTextSelected: boolean = false; + selectedMetadataField: string = ''; + filterAutomatically: boolean = true; + concatenateText: boolean = false; + + // Configuración de metadatos + metadataFormOptions = []; + metadataOptions = []; + metadataSearchTerm: string = ''; + repeatableMetadata: string[] = MetadataConfig.REPEATABLE_METADATA; + peopleMetadata: string[] = MetadataConfig.PEOPLE_METADATA; + + // Configuración de filtros + filterOptions: FilterInfo[]; + private generalMetadataFilter: FilterInfo[] = FilterConfig.GENERAL_METADATA_FILTER; + private repeatableMetadataFilter: FilterInfo[] = FilterConfig.REPEATABLE_METADATA_FILTER; + private peopleMetadataFilter: FilterInfo[] = FilterConfig.PEOPLE_METADATA_FILTER; + + // Estado de elementos dinámicos + showDynamicInputs: boolean = false; + isDropdownOpen = false; + applyFilterToSubmissionField: boolean = false; + private mutationObserver: MutationObserver | null = null; + private fieldButtonMap = new Map, subscription: any }>(); + private globalIdCounters = new Map(); + + constructor( + private changeDetectorRef: ChangeDetectorRef, + private viewContainerRef: ViewContainerRef, + private ngZone: NgZone, + private formOperationsService: SectionFormOperationsService + ) { } + + ngOnInit() { + this.updateFilterOptions(); + } + + updateFilterOptions(): void { + this.filterOptions = [...this.generalMetadataFilter]; + + const idPart = this.extractIdPart(); + + if (this.repeatableMetadata.includes(idPart)) { + this.filterOptions = this.filterOptions.concat(this.repeatableMetadataFilter); + } + + if (this.peopleMetadata.includes(idPart)) { + this.filterOptions = this.filterOptions.concat(this.peopleMetadataFilter); + } + } + + onMetadataSearchChange(): void { + const selectedOption = this.getSelectedMetadataOption(); + const searchTerm = this.normalizeMetadataSearchText(this.metadataSearchTerm); + const selectedName = this.normalizeMetadataSearchText(selectedOption?.name || ''); + + if (this.selectedMetadataField && searchTerm !== selectedName) { + this.selectedMetadataField = ''; + this.updateFilterOptions(); + } + } + + private getFilteredMetadataOptions(term: string = this.metadataSearchTerm) { + const normalizedTerm = this.normalizeMetadataSearchText(term); + if (!normalizedTerm) { + return this.metadataOptions; + } + + return this.metadataOptions.filter((option: any) => { + const name = this.normalizeMetadataSearchText(option?.name || ''); + return name.includes(normalizedTerm); + }); + } + + metadataFieldFormatter = (option: { name: string }) => { + return (typeof option === 'object' && option?.name) ? option.name : (option as unknown as string); + }; + + metadataFieldSearch = (text$: Observable) => { + return text$.pipe( + debounceTime(120), + distinctUntilChanged(), + map((term) => this.getFilteredMetadataOptions(term).slice(0, 25)), + ); + }; + + onMetadataFieldSelected(event: NgbTypeaheadSelectItemEvent): void { + const option = event.item as any; + if (!option) { + return; + } + + this.selectedMetadataField = option.value; + this.metadataSearchTerm = option.name; + this.updateFilterOptions(); + } + + private normalizeMetadataSearchText(value: string): string { + return (value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + + private getSelectedMetadataOption(): any { + return this.metadataOptions.find((option: any) => option.value === this.selectedMetadataField); + } + + private syncMetadataSearchTermWithSelection(): void { + const selectedOption = this.getSelectedMetadataOption(); + this.metadataSearchTerm = selectedOption ? selectedOption.name : ''; + } + + ngAfterViewInit() { + this.syncButtonsWithFields(); + this.interceptFormOperationChanges(); + this.setupMutationObserver(); + } + + ngOnDestroy() { + this.resetButtonCounters(); + + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + + if (this.patchTimeout1) clearTimeout(this.patchTimeout1); + if (this.patchTimeout2) clearTimeout(this.patchTimeout2); + if (this.debounceTimer) clearTimeout(this.debounceTimer); + + if (this.originalDispatch) { + this.formOperationsService.dispatchOperationsFromChangeEvent = this.originalDispatch; + this.originalDispatch = null; + } + + if (this.viewerContainerRef && this.boundTextSelectedHandler) { + this.viewerContainerRef.removeEventListener('mouseup', this.boundTextSelectedHandler); + } + } + + private resetButtonCounters(): void { + this.globalIdCounters.clear(); + + this.fieldButtonMap.forEach((value) => { + if (value.subscription) value.subscription.unsubscribe(); + if (value.componentRef) value.componentRef.destroy(); + }); + this.fieldButtonMap.clear(); + + const elementsWithKeys = document.querySelectorAll('[data-unique-key]'); + elementsWithKeys.forEach(element => { + element.removeAttribute('data-unique-key'); + }); + } + + private originalDispatch: any = null; + private patchTimeout1: any = null; + private patchTimeout2: any = null; + private interceptFormOperationChanges(): void { + if (this.originalDispatch) { + return; + } + + this.originalDispatch = this.formOperationsService.dispatchOperationsFromChangeEvent; + + this.formOperationsService.dispatchOperationsFromChangeEvent = + (pathCombiner: JsonPatchOperationPathCombiner, event: any, previousValue: any, hasStoredValue: boolean) => { + if (event?.model?.id === 'dc_type' || event?.model?.id === 'sedici_subtype') { + if (this.patchTimeout1) clearTimeout(this.patchTimeout1); + if (this.patchTimeout2) clearTimeout(this.patchTimeout2); + this.patchTimeout1 = setTimeout(() => { + this.debounceUpdateButtons(); + this.patchTimeout2 = setTimeout(() => { + this.removeDuplicateButtons(); + }, 200); + }, 500); + } + return this.originalDispatch.call(this.formOperationsService, pathCombiner, event, previousValue, hasStoredValue); + }; + } + + private removeDuplicateButtons(): void { + const allButtons = document.querySelectorAll('app-dynamic-button-dropdown[data-field-key]'); + const fieldKeyGroups = new Map(); + + allButtons.forEach((button) => { + const fieldKey = button.getAttribute('data-field-key'); + if (fieldKey) { + if (!fieldKeyGroups.has(fieldKey)) { + fieldKeyGroups.set(fieldKey, []); + } + fieldKeyGroups.get(fieldKey)!.push(button); + } + }); + + // Procesar grupos con duplicados + fieldKeyGroups.forEach((buttons, fieldKey) => { + if (buttons.length > 1) { + let notFunctionalButton: Element | null = null; + const buttonsToRemove: Element[] = []; + + buttons.forEach(button => { + if (this.fieldButtonMap.has(fieldKey)) { + const mapValue = this.fieldButtonMap.get(fieldKey); + if (mapValue && mapValue.componentRef.location.nativeElement === button) { + notFunctionalButton = button; + buttonsToRemove.push(button); + } + } + }); + + // Si no encontramos el notFuncional en el mapa, mantener el primero + if (!notFunctionalButton && buttons.length > 0) { + notFunctionalButton = buttons[0]; + buttonsToRemove.splice(0, 1); + } + + // Eliminar los botones duplicados + buttonsToRemove.forEach(button => { + button.remove(); + }); + } + }); + } + + private addFocusTrackingToInput(element: HTMLElement): void { + element.addEventListener('focus', (event) => { + const targetId = (event.target as HTMLTextAreaElement | HTMLInputElement).id; + const excludedIds = MetadataConfig.EXCLUDED_IDS; + // Evito el focus en la caja de previsualización de texto y en los campos deplegables + if (!excludedIds.has(targetId) && !targetId.includes('input-')) { + this.selectedMetadataField = targetId; + this.syncMetadataSearchTermWithSelection(); + this.updateFilterOptions(); + this.changeDetectorRef.detectChanges(); + } + }); + } + + private setupMutationObserver(): void { + this.mutationObserver = new MutationObserver((mutations) => { + let hasRelevantChanges = false; + + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + // Verificar si se agregaron o eliminaron campos de formulario + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if (this.isFormField(element)) { + hasRelevantChanges = true; + } + } + }); + + mutation.removedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if (this.isFormField(element)) { + hasRelevantChanges = true; + this.cleanupOrphanedButtons(element); + } + } + }); + } + }); + + if (hasRelevantChanges) { + this.debounceUpdateButtons(); + } + }); + + const formContainer = document.querySelector('ds-submission-form'); + if (formContainer) { + this.mutationObserver.observe(formContainer, { + childList: true, + subtree: true + }); + } else { + console.warn('No se encontró el contenedor del formulario para MutationObserver.'); + } + } + + private isFormField(element: Element): boolean { + const tagName = element.tagName.toLowerCase(); + return tagName === 'ds-dynamic-onebox' || tagName === 'dynamic-ng-bootstrap-textarea' || tagName === 'dynamic-ng-bootstrap-input'; + } + + private debounceTimer: any; + private debounceUpdateButtons(): void { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.syncButtonsWithFields(); + }, 100); + } + + private syncButtonsWithFields(): void { + const currentFields = this.getCurrentFormFields(); + const currentFieldKeys = new Set(currentFields.map(field => field.getAttribute('data-unique-key'))); + + // 1. Limpiar botones huérfanos desde el DOM ANTES de otras operaciones + this.removeOrphanedButtonsFromDOM(currentFieldKeys); + + // 2. Eliminar botones huérfanos del mapa + this.removeOrphanedButtons(currentFieldKeys); + + // 3. Agregar botones a campos nuevos + this.addButtonsToNewFields(currentFields); + + // 4. Actualizar opciones de metadatos + this.updateMetadataOptions(currentFields); + } + + private removeOrphanedButtonsFromDOM(currentFieldKeys: Set): void { + const allButtonsInDOM = document.querySelectorAll('app-dynamic-button-dropdown[data-field-key]'); + + allButtonsInDOM.forEach((button) => { + const fieldKey = button.getAttribute('data-field-key'); + if (fieldKey && !currentFieldKeys.has(fieldKey)) { + if (this.fieldButtonMap.has(fieldKey)) { + const mapValue = this.fieldButtonMap.get(fieldKey); + if (mapValue) { + mapValue.subscription.unsubscribe(); + mapValue.componentRef.destroy(); + } + this.fieldButtonMap.delete(fieldKey); + } + button.remove(); + } + }); + } + + private removeOrphanedButtons(currentFieldKeys: Set): void { + const orphanedKeys: string[] = []; + + this.fieldButtonMap.forEach((value, fieldKey) => { + if (!currentFieldKeys.has(fieldKey)) { + value.subscription.unsubscribe(); + value.componentRef.destroy(); + orphanedKeys.push(fieldKey); + } + }); + + orphanedKeys.forEach(key => this.fieldButtonMap.delete(key)); + } + + private addButtonsToNewFields(currentFields: HTMLElement[]): void { + const excludedIds = MetadataConfig.EXCLUDED_IDS_FOR_BUTTONS; + currentFields = currentFields + .filter(element => !excludedIds.has(element.id)) + + currentFields.forEach(field => { + const uniqueKey = field.getAttribute('data-unique-key'); + if (uniqueKey && !this.fieldButtonMap.has(uniqueKey)) { + this.createButtonForField(field); + } + }); + } + + private createButtonForField(input: HTMLElement): void { + if (!input.id) return; + + const uniqueKey = input.getAttribute('data-unique-key'); + if (!uniqueKey) return; + + const existingButton = document.querySelector(`app-dynamic-button-dropdown[data-field-key="${uniqueKey}"]`); + if (existingButton) { + return; + } + + const rightAddon = input.closest('.right-addon') as HTMLElement | null; + if (rightAddon) { + const componentRef = this.createButtonComponentt(input); + componentRef.location.nativeElement.setAttribute('data-field-key', uniqueKey); + const wrapper = rightAddon.parentElement as HTMLElement | null; + if (wrapper) { + wrapper.classList.add('pdf-viewer-layout-wrapper'); + rightAddon.classList.add('pdf-viewer-layout-element'); + wrapper.appendChild(componentRef.location.nativeElement); + } else { + rightAddon.insertAdjacentElement('afterend', componentRef.location.nativeElement); + } + return; + } + + const ngBootstrapInput = input.closest('dynamic-ng-bootstrap-input') as HTMLElement | null; + if (ngBootstrapInput) { + const componentRef = this.createButtonComponentt(input); + componentRef.location.nativeElement.setAttribute('data-field-key', uniqueKey); + const wrapper = input.parentElement as HTMLElement | null; + if (wrapper) { + wrapper.classList.add('pdf-viewer-layout-wrapper'); + input.classList.add('pdf-viewer-layout-element'); + wrapper.appendChild(componentRef.location.nativeElement); + } else { + ngBootstrapInput.insertAdjacentElement('afterend', componentRef.location.nativeElement); + } + return; + } + + const parent = input.closest('.col') || input.closest('ds-dynamic-form-control-container'); + if (!parent) return; + + const componentRef = this.createButtonComponentt(input); + componentRef.location.nativeElement.setAttribute('data-field-key', uniqueKey); + parent.insertAdjacentElement('afterend', componentRef.location.nativeElement); + } + + private createButtonComponentt(input: HTMLElement): ComponentRef { + const componentRef = this.viewContainerRef.createComponent(DynamicButtonDropdownComponent); + + const match = input.id.match(/(dc|sedici|mods|thesis).*/); + componentRef.instance.inputID = match ? match[0] : input.id; + + const sub = componentRef.instance.filterApplied.subscribe((filter: string) => { + this.applyFilterToSubmissionField = true; + const newValue = this.applyFilter(filter, (input as HTMLInputElement).value); + this.setMetadataValue(input as HTMLInputElement, newValue); + }); + + const uniqueKey = input.getAttribute('data-unique-key'); + if (uniqueKey) { + this.fieldButtonMap.set(uniqueKey, { componentRef, subscription: sub }); + } + + return componentRef; + } + + private getCurrentFormFields(): HTMLElement[] { + const formContainers = document.querySelectorAll('ds-dynamic-form-control-container'); + const fields: HTMLElement[] = []; + const excludedIds = MetadataConfig.EXCLUDED_IDS; + + formContainers.forEach((container) => { + const inputs = Array.from(container.querySelectorAll('input, textarea')) + .filter((input): input is HTMLElement => input instanceof HTMLElement) + .filter(input => input.id) + .filter(input => window.getComputedStyle(input).visibility === 'visible') + .filter(input => !excludedIds.has(input.id) && + !input.id.match(/^primaryBitstream\d+$/) && + !input.id.match(/^inputFileUploader-ds-drag-and-drop-uploader\d+$/) && + !input.id.match(/^SL_locer\d+$/) && + !input.id.match(/^SL_BBL_locer\d+$/) && + input.id !== 'cc-license-dropdown' && + !(input.id).includes('input-')) + + inputs.forEach((input) => { + if (input.id) { + const element = input as HTMLElement; + let uniqueKey = element.getAttribute('data-unique-key'); + if (!uniqueKey) { + const currentCount = this.globalIdCounters.get(element.id) || 0; + this.globalIdCounters.set(element.id, currentCount + 1); + uniqueKey = this.generateUniqueKey(element, currentCount + 1); + element.setAttribute('data-unique-key', uniqueKey); + this.addFocusTrackingToInput(element); + } + fields.push(element); + } + }); + }); + + return fields; + } + + private generateUniqueKey(element: HTMLElement, globalInstanceIndex: number): string { + return `${element.id}-instance-${globalInstanceIndex}`; + } + + private cleanupOrphanedButtons(removedElement: Element): void { + const inputs = removedElement.querySelectorAll('input[data-unique-key], textarea[data-unique-key]'); + inputs.forEach(input => { + const uniqueKey = input.getAttribute('data-unique-key'); + if (uniqueKey && this.fieldButtonMap.has(uniqueKey)) { + const mapValue = this.fieldButtonMap.get(uniqueKey); + if (mapValue) { + mapValue.subscription.unsubscribe(); + mapValue.componentRef.destroy(); + } + this.fieldButtonMap.delete(uniqueKey); + } + }); + } + + private updateMetadataOptions(elements: HTMLElement[]): void { + const uniqueId = new Set(); + const nameMap = MetadataConfig.NAME_MAP; + + this.metadataOptions = elements + .filter(input => { + if (uniqueId.has(input.id)) return false; + uniqueId.add(input.id); + return true; + }) + .map(element => ({ + name: nameMap[element.id] || element.getAttribute('aria-label') || element.getAttribute('placeholder') || element.getAttribute('name') || element.id, + value: element.id + })); + + this.syncMetadataSearchTermWithSelection(); + } + + private boundTextSelectedHandler: (event: MouseEvent) => void; + private viewerContainerRef: any = null; + public pagesLoadedEvent(): void { + this.iframe = this.pdfViewerOnDemand.iframe.nativeElement; + this.container = this.iframe.contentDocument.body; + const pdfApp = this.iframe.contentWindow?.PDFViewerApplication; + + if (pdfApp?.appConfig?.viewerContainer) { + this.viewerContainerRef = pdfApp.appConfig.viewerContainer; + this.boundTextSelectedHandler = this.onTextSelected.bind(this); + this.viewerContainerRef.addEventListener('mouseup', this.boundTextSelectedHandler); + } + } + + onTextSelected(event) { + const selection = event.view.getSelection(); + if (selection && selection.toString().length > 0) { + this.handleTextSelection(selection, event); + } else { + this.clearTextSelection(); + this.showDynamicInputs = false; + } + this.changeDetectorRef.detectChanges(); + } + + private handleTextSelection(selection: Selection, event: any): void { + let selectionToString = selection.toString(); + if (this.filterAutomatically) { + selectionToString = filterTransformer.cleanText(selectionToString, this.extractIdPart()); + if (this.concatenateText) { + selectionToString = this.selectedText + ' ' + selectionToString; + this.concatenateText = false; + } + if (this.selectedMetadataField !== '') { + const idPart = this.extractIdPart(); + if (this.peopleMetadata.includes(idPart)) { + const parts = filterTransformer.transformPersons(selectionToString); + if (parts.length > 1) { + this.showDynamicInputs = true; + this.changeDetectorRef.detectChanges(); + setTimeout(() => { + this.createInputs(parts); + }, 100); + this.selectedText = selectionToString; + } else { + this.selectedText = parts[0]; + } + } else if (idPart === 'dc_subject') { + const parts = filterTransformer.transformKeywords(selectionToString); + if (parts.length > 1) { + this.showDynamicInputs = true; + this.changeDetectorRef.detectChanges(); + setTimeout(() => { + this.createInputs(parts); + }, 100); + this.selectedText = selectionToString; + } else { + this.selectedText = parts[0]; + } + } else if (idPart === 'sedici_relation_journalVolumeAndIssue') { + this.selectedText = this.processJournalMetadata(selectionToString); + } else if (idPart.includes('date')) { + this.selectedText = this.processDateMetadata(selectionToString); + } else { + this.selectedText = selectionToString; + } + } else { + this.selectedText = selectionToString; + } + } else { + if (this.concatenateText) { + selectionToString = this.selectedText + ' ' + selectionToString; + this.concatenateText = false; + } + this.selectedText = selectionToString; + } + + this.isTextSelected = true; + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + this.centerSelectionInView(rect); + + // Espero a que termine el scroll para crear los botones con la posición final + setTimeout(() => { + const updatedRect = selection.getRangeAt(0).getBoundingClientRect(); + this.createButtons(updatedRect); + }, 450); + } + + /** + * Centra la selección de texto en el visor de PDF + * @param rect Rectángulo que contiene la selección de texto + */ + private centerSelectionInView(rect: DOMRect): void { + if (!this.iframe || !this.iframe.contentWindow) return; + + const viewerContainer = this.iframe.contentWindow.document.getElementById('viewerContainer'); + if (!viewerContainer) return; + + const containerHeight = viewerContainer.clientHeight; + const selectionTop = rect.top + viewerContainer.scrollTop - this.iframe.getBoundingClientRect().top; + const selectionHeight = rect.height; + const targetScrollTop = selectionTop - (containerHeight / 2) + (selectionHeight / 2); + + viewerContainer.scrollTo({ + top: targetScrollTop, + behavior: 'smooth' + }); + } + + clearTextSelection(): void { + this.selectedText = ''; + this.isTextSelected = false; + this.concatenateText = false; + this.removeButtons(); + } + + changeFilterAutomatically(): void { + this.filterAutomatically = !this.filterAutomatically; + } + + changeConcatenateText(): void { + this.concatenateText = !this.concatenateText; + } + + createButtons(rect: DOMRect): void { + this.ngZone.run(() => { + this.removeButtons(); + const componentRef = this.createButtonComponent(); + this.configureButtonComponent(componentRef, rect); + this.buttonComponents.push(componentRef); + this.changeDetectorRef.detectChanges(); + }); + } + + private createButtonComponent(): ComponentRef { + return this.viewContainerRef.createComponent(ShortcutsButtonsComponent); + } + + private configureButtonComponent(componentRef: ComponentRef, rect: DOMRect): void { + componentRef.instance.rect = rect; + componentRef.instance.onButtonClick = () => { + this.copyToMetadataField(); + }; + } + + removeButtons(): void { + this.buttonComponents.forEach(componentRef => componentRef.destroy()); + this.buttonComponents = []; + } + + copyToMetadataField() { + if (!this.selectedMetadataField) { + alert('Selecciona una caja de texto.'); + return; + } + + const idPart = this.extractIdPart(); + const text = this.selectedText.trim(); + + if (this.showDynamicInputs) { + this.retrieveInputs(); + } else if (idPart.includes('date')) { + this.saveDateMetadata(); + } else if (this.isRepeatableMetadataName(idPart)) { + this.processRepeatableMetadata([text]); + } else { + this.processStandardMetadata(); + } + + this.removeButtons(); + this.clearTextSelection(); + this.selectedMetadataField = ''; + this.metadataSearchTerm = ''; + } + + private processDateMetadata(selectedText: string): string { + const date = new DateSplitter().split(selectedText); + let formattedText = ''; + + if (date.year) { + if (date.day && date.month) { + // Formato completo: "DIA del MES del AÑO" + formattedText = `Día: ${date.day}\nMes: ${date.month}\nAño: ${date.year}`; + } else if (date.month) { + // Solo mes y año: "MES del AÑO" + formattedText = `Mes: ${date.month}\nAño: ${date.year}`; + } else { + // Solo año: "AÑO" + formattedText = `Año: ${date.year}`; + } + return formattedText; + } else { + alert('No se encontró un formato de fecha válido'); + return selectedText; + } + } + + private saveDateMetadata(): void { + let day = ''; + let month = ''; + let year = ''; + + // Extraer año (4 dígitos después de "Año: ") + const yearMatch = this.selectedText.match(/Año:\s*(\d{4})/); + if (yearMatch) { + year = yearMatch[1]; + } + + // Extraer mes (1-2 dígitos después de "Mes: ") + const monthMatch = this.selectedText.match(/Mes:\s*(\d{1,2})/); + if (monthMatch) { + month = monthMatch[1]; + } + + // Extraer día (1-2 dígitos después de "Día: ") + const dayMatch = this.selectedText.match(/Día:\s*(\d{1,2})/); + if (dayMatch) { + day = dayMatch[1]; + } + + // Si no hay ninguno de los tres, usar DateSplitter + if (!year && !month && !day) { + const date = new DateSplitter().split(this.selectedText.trim()); + day = date.day || ''; + month = date.month || ''; + year = date.year || ''; + } + + const metadataYear = document.getElementById(this.selectedMetadataField) as HTMLInputElement | null; + const metadataMonth = document.getElementById(this.selectedMetadataField.replace(/_year$/, '_month')) as HTMLInputElement | null; + const metadataDay = document.getElementById(this.selectedMetadataField.replace(/_year$/, '_day')) as HTMLInputElement | null; + + if (year && metadataYear) { + this.setMetadataValue(metadataYear, year); + if (month && metadataMonth) { + this.setMetadataValue(metadataMonth, month); + if (day && metadataDay) { + this.setMetadataValue(metadataDay, day); + } else if (metadataDay) { + metadataDay.value = ''; + } + } else if (metadataMonth) { + metadataMonth.value = ''; + if (metadataDay) metadataDay.value = ''; + } + } else { + console.error('El campo de metadato de año no se encuentra disponible en el DOM.'); + } + } + + private processJournalMetadata(selectedText: string): string { + const journalData = new VolumeIssueSplitter().split(selectedText); + let formattedText = ''; + + if (journalData.volume) { + formattedText = journalData.issue + ? `vol. ${journalData.volume}, no. ${journalData.issue}` + : `vol. ${journalData.volume}`; + } else if (journalData.year) { + formattedText = journalData.issue + ? `año ${journalData.year}, no. ${journalData.issue}` + : `año ${journalData.year}`; + } else if (journalData.tomo) { + formattedText = journalData.issue + ? `tomo ${journalData.tomo}, no. ${journalData.issue}` + : `tomo ${journalData.tomo}`; + } else if (journalData.issue) { + formattedText = `no. ${journalData.issue}`; + } else { + alert('No se encontraron año, volumen, tomo o número'); + return selectedText; + } + + return formattedText; + } + + private processStandardMetadata(): void { + const elements = document.querySelectorAll(`[id*="${this.selectedMetadataField}"]`); + const element = Array.from(elements).find(el => + window.getComputedStyle(el).visibility === 'visible' && + el.getAttribute('id').startsWith('label') === false + ) as HTMLTextAreaElement | HTMLInputElement; + + this.setMetadataValue(element, this.selectedText); + } + + extractIdPart(): string { + const match = this.selectedMetadataField.match(/(dc|sedici|mods|thesis).*/); + return match ? match[0] : this.selectedMetadataField; + } + + isRepeatableMetadataName(name: string): boolean { + return this.repeatableMetadata.includes(name); + } + + async addMetadataField(selectedMetadataField: string = this.selectedMetadataField): Promise { + const selectedInput = document.getElementById(selectedMetadataField); + if (!selectedInput) { + console.error('Input seleccionado no encontrado.'); + return; + } + + const metadataContainer = selectedInput.closest('div[id$="_array"]'); + if (!metadataContainer) { + console.error('Contenedor no encontrado.'); + return; + } + + const addButton = metadataContainer.querySelector('.ds-form-add-more.btn.btn-link') as HTMLElement; + if (!addButton) { + console.error(`Botón "Añadir más" no encontrado en el contenedor con id ${metadataContainer.id}.`); + return; + } + + addButton.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + async processRepeatableMetadata( + items: string[], + idPart: string = this.extractIdPart(), + selectedMetadataField: string = this.selectedMetadataField + ): Promise { + for (const item of items) { + const elementsWithSameId = this.getVisibleElementsWithId(idPart); + let element = this.findEmptyElement(elementsWithSameId); + + if (!element) { + await this.addMetadataField(selectedMetadataField); + const newElementsWithSameId = this.getVisibleElementsWithId(idPart); + element = newElementsWithSameId[newElementsWithSameId.length - 1] as HTMLTextAreaElement | HTMLInputElement; + } + + this.setMetadataValue(element, item); + } + } + + private getVisibleElementsWithId(idPart: string): Array { + return Array.from(document.querySelectorAll(`[id*="${idPart}"]`)) + .filter(element => { + const id = element.getAttribute('id'); + return id === idPart || id.endsWith(idPart); + }) + .filter(element => window.getComputedStyle(element).visibility === 'visible') as Array; + } + + private findEmptyElement(elements: Array): HTMLTextAreaElement | HTMLInputElement | null { + let index = 0; + let element = elements[index]; + + while (element && element.value) { + element = elements[++index]; + } + + return element || null; + } + + setMetadataValue(element: HTMLTextAreaElement | HTMLInputElement, value: string): void { + if (!element || value === '') { + alert(`Elemento con ID ${element?.id || 'desconocido'} no encontrado. O valor no válido.`); + return; + } + + // 1. Simular entrada del usuario (Ganar foco) + element.focus(); + this.selectedMetadataField = ''; + this.modifiedFieldStyle(element); + + // 2. Asignar el valor extraído del PDF + element.value = value; + + // 3. Notificar a Angular el cambio de valor + this.triggerDOMEvents(element); + + // 4. Forzar la pérdida de foco para que DSpace dispare el auto-salvado y NgRx persista los datos + element.blur(); + + this.removeButtons(); + } + + private triggerDOMEvents(element: HTMLTextAreaElement | HTMLInputElement): void { + const inputEvent = new Event('input', { bubbles: true, cancelable: true }); + element.dispatchEvent(inputEvent); + + const changeEvent = new Event('change', { bubbles: true, cancelable: true }); + element.dispatchEvent(changeEvent); + + this.changeDetectorRef.detectChanges(); + } + + modifiedFieldStyle(element: HTMLTextAreaElement | HTMLInputElement): void { + if (!element) return; + + element.classList.add('status-modified'); + + setTimeout(() => { + element.classList.remove('status-modified'); + }, 5000); + } + + modifyPeopleFieldStyle(part: string, element: HTMLTextAreaElement | HTMLInputElement): void { + if (!element) return; + + element.classList.remove('people-valid', 'people-warning', 'people-invalid'); + + if (part.includes(',')) { + const length = part.split(",").join(" ").trim().split(/\s+/).filter(Boolean).length; + + if (length === 2) { + element.classList.add('people-valid'); + } else if (length > 2 && length <= 4) { + element.classList.add('people-warning'); + } else { + element.classList.add('people-invalid'); + } + } else { + element.classList.add('people-invalid'); + } + } + + createInputs(parts: string[]): void { + const container = document.getElementById('inputsContainer'); + if (!container) return; + + container.innerHTML = ''; + + parts.forEach((part, index) => { + const inputGroup = this.createInputGroup(part, index, container); + container.appendChild(inputGroup); + }); + + let removeAllButton = document.querySelector('.dynamic-remove-all-button'); + if (!removeAllButton) { + removeAllButton = this.createRemoveAllButton(container); + } + container.insertAdjacentElement('afterend', removeAllButton); + } + + private createInputGroup(part: string, index: number, container: HTMLElement): HTMLElement { + const inputGroup = document.createElement('div'); + inputGroup.classList.add('dynamic-input-group'); + + const input = document.createElement('input'); + input.type = 'text'; + input.value = part; + input.id = `input-${index}`; + input.classList.add('dynamic-input'); + + const idPart = this.extractIdPart(); + if (this.peopleMetadata.includes(idPart)) { + this.modifyPeopleFieldStyle(part, input); + } + + const removeButton = document.createElement('button'); + removeButton.innerText = 'x'; + removeButton.classList.add('dynamic-remove-button'); + removeButton.addEventListener('click', () => this.handleRemoveInput(inputGroup, container)); + + inputGroup.appendChild(input); + inputGroup.appendChild(removeButton); + + return inputGroup; + } + + private handleRemoveInput(inputGroup: HTMLElement, container: HTMLElement): void { + container.removeChild(inputGroup); + const inputs = document.querySelectorAll('.dynamic-input'); + if (inputs.length === 0) { + const removeAllButton = document.querySelector('.dynamic-remove-all-button'); + if (removeAllButton) removeAllButton.remove(); + this.showDynamicInputs = false; + } + } + + private createRemoveAllButton(container: HTMLElement): HTMLElement { + const removeAllButton = document.createElement('button'); + removeAllButton.innerText = 'Eliminar todos'; + removeAllButton.classList.add('dynamic-remove-all-button'); + removeAllButton.addEventListener('click', () => { + const inputs = document.querySelectorAll('.dynamic-input-group'); + inputs.forEach(inputGroup => { + container.removeChild(inputGroup); + }); + removeAllButton.remove(); + this.showDynamicInputs = false; + }); + + return removeAllButton; + } + + clearDynamicInputs(): void { + this.showDynamicInputs = false; + const container = document.getElementById('inputsContainer'); + if (container) container.innerHTML = ''; + } + + async retrieveInputs(): Promise { + const inputs = document.querySelectorAll('.dynamic-input'); + const idPart = this.extractIdPart(); + const selectedMetadataField = this.selectedMetadataField; + + for (const input of Array.from(inputs)) { + const element = input as HTMLTextAreaElement | HTMLInputElement; + await this.processRepeatableMetadata([element.value], idPart, selectedMetadataField); + } + + this.showDynamicInputs = false; + } + + applyFilter(filter: string, text: string = this.selectedText): string { + if (this.applyFilterToSubmissionField) { + this.applyFilterToSubmissionField = false; + return this.applyFilterToText(text, filter); + } else if (this.showDynamicInputs){ + this.applyFilterToDynamicInputs(filter); + } else { + this.selectedText = this.applyFilterToText(text, filter); + return this.selectedText; + } + return ''; + } + + private applyFilterToDynamicInputs(filter: string): void { + const inputs = document.querySelectorAll('.dynamic-input'); + inputs.forEach((input) => { + const element = input as HTMLTextAreaElement | HTMLInputElement; + element.value = this.applyFilterToText(element.value, filter, element); + if (element.value === '') { + element.parentElement?.remove(); + } + }); + } + + applyFilterToText(text: string, filter: string, element?): string { + switch (filter) { + case 'camelCase': + return filterTransformer.toCamelCase(text); + case 'upperCase': + return filterTransformer.toUpperCase(text); + case 'lowerCase': + return filterTransformer.toLowerCase(text); + case 'capitalize': + return filterTransformer.toCapitalize(text); + case 'cleanText': + return filterTransformer.cleanText(text, this.extractIdPart()); + case 'splitByDelimiter': + return this.handleSplitByDelimiter(text); + case 'removeDoubleSpaces': + return filterTransformer.removeDoubleSpaces(text); + case 'removeSpacesBetweenLetters': + return filterTransformer.removeSpacesBetweenLetters(text); + case 'removeSpacesAtStartAndEnd': + return filterTransformer.removeSpacesAtStartAndEnd(text); + case 'removeLineBreaks': + return filterTransformer.removeLineBreaks(text); + case 'removeTitles': + return filterTransformer.removeTitles(text); + case 'removeReferences': + return filterTransformer.removeReferences(text); + case 'reorderPerson': + if (element) { + return this.handleReorderPerson(text, element); + } + return this.handleReorderPerson(text); + default: + return text; + } + } + + private handleSplitByDelimiter(text: string): string { + const parts = filterTransformer.splitByDelimiter(text); + this.showDynamicInputs = true; + this.changeDetectorRef.detectChanges(); + this.createInputs(parts); + return text; + } + + private handleReorderPerson(text: string, element?): string { + const result = filterTransformer.reorderPerson(text); + if (element) { + this.modifyPeopleFieldStyle(result, element); + } + return result; + } + + toggleDropdown(): void { + this.isDropdownOpen = !this.isDropdownOpen; + } +} \ No newline at end of file diff --git a/src/app/submission/form/shortcuts-buttons.component.html b/src/app/submission/form/shortcuts-buttons.component.html new file mode 100644 index 00000000000..1a65d725b99 --- /dev/null +++ b/src/app/submission/form/shortcuts-buttons.component.html @@ -0,0 +1,11 @@ +
+ @for (config of buttonsConfig; track config) { + + } +
\ No newline at end of file diff --git a/src/app/submission/form/shortcuts-buttons.component.scss b/src/app/submission/form/shortcuts-buttons.component.scss new file mode 100644 index 00000000000..8d112fb36d5 --- /dev/null +++ b/src/app/submission/form/shortcuts-buttons.component.scss @@ -0,0 +1,15 @@ +.button-group { + position: absolute; + transform: translateX(-50%); + z-index: 1000; + display: flex; + gap: 0px; +} + +.selection-button { + color: #fff; + border: none; + cursor: pointer; + width: 35px; + height: 30px; +} \ No newline at end of file diff --git a/src/app/submission/form/shortcuts-buttons.component.ts b/src/app/submission/form/shortcuts-buttons.component.ts new file mode 100644 index 00000000000..9327fe23370 --- /dev/null +++ b/src/app/submission/form/shortcuts-buttons.component.ts @@ -0,0 +1,23 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-shortcuts-buttons', + templateUrl: './shortcuts-buttons.component.html', + styleUrls: ['./shortcuts-buttons.component.scss'], + standalone: true, + imports: [CommonModule] +}) +export class ShortcutsButtonsComponent { + @Input() rect: DOMRect; + @Input() onButtonClick: () => void; + + buttonsConfig = [ + { + text: 'Enviar al campo', + label: '<--', + action: () => this.onButtonClick?.(), + color: '#000000' + } + ]; +} \ No newline at end of file diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html index ed648751cb0..6c65430973d 100644 --- a/src/app/submission/form/submission-form.component.html +++ b/src/app/submission/form/submission-form.component.html @@ -1,48 +1,61 @@ -
- @if ((isLoading$ | async) !== true) { -
- @if ((uploadEnabled$ | async)) { -
- -
-
- } -
- @if (!isSectionHidden) { - - +
+
+ @if ((isLoading$ | async) !== true) { +
+ @if ((uploadEnabled$ | async)) { +
+ + +
+
} +
+ @if (!isSectionHidden) { + + + } +
+
+ + +
-
- - -
-
- } - -
- @if (isLoading$ | async) { - - } - @for (object of $any(submissionSections | async); track object) { - - } + +
+ @if ((isLoading$ | async)) { + + } + @for (object of $any(submissionSections | async); track object) { + + + } +
- @if ((isLoading$ | async) !== true) { - +@if ((isLoading$ | async) !== true) { + +} diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss index 772849791ca..a547422c9fe 100644 --- a/src/app/submission/form/submission-form.component.scss +++ b/src/app/submission/form/submission-form.component.scss @@ -9,6 +9,14 @@ flex-grow: 1; } +.submission-pdf-viewer { + position: sticky; + width: 100%; + height: 500px; + top: 0; + padding-top: 8px; +} + .submission-form-footer { border-radius: var(--bs-card-border-radius); bottom: 0; @@ -37,3 +45,22 @@ text-decoration: $link-hover-decoration; } } + +.selection-menu-bottom { + background-color: #f9f9f9; + border: 1px solid #ccc; + padding: 10px; + margin: 20px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; + + select, button { + margin: 5px 0; + } +} + +canvas { + border: 1px solid black; + width: 100%; + height: auto; +} diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 25699b23fef..b4138026fc0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -5,6 +5,7 @@ import { Input, OnChanges, OnDestroy, + OnInit, SimpleChanges, } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; @@ -14,6 +15,7 @@ import { of, Subscription, } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; import { distinctUntilChanged, filter, @@ -27,6 +29,7 @@ import { SubmissionSectionModel } from '../../core/config/models/config-submissi import { Collection } from '../../core/shared/collection.model'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { Item } from '../../core/shared/item.model'; +import { EPerson } from 'src/app/core/eperson/models/eperson.model'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; import { @@ -50,6 +53,9 @@ import { ThemedSubmissionFormFooterComponent } from './footer/themed-submission- import { SubmissionFormSectionAddComponent } from './section-add/submission-form-section-add.component'; import { ThemedSubmissionUploadFilesComponent } from './submission-upload-files/themed-submission-upload-files.component'; +import { FormsModule } from '@angular/forms'; + +import { PdfViewerComponent } from './pdf-viewer.component'; /** * This component represents the submission form. */ @@ -66,9 +72,11 @@ import { ThemedSubmissionUploadFilesComponent } from './submission-upload-files/ ThemedSubmissionSectionContainerComponent, ThemedSubmissionUploadFilesComponent, TranslatePipe, - ], + FormsModule, + PdfViewerComponent +], }) -export class SubmissionFormComponent implements OnChanges, OnDestroy { +export class SubmissionFormComponent implements OnChanges, OnDestroy, OnInit { /** * The collection id this submission belonging to @@ -156,6 +164,10 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { */ protected subs: Subscription[] = []; + public pdfBlobUrl: string | null = null; + + public isAuthorizedForPdfViewer: boolean = false; + /** * Initialize instance variables * @@ -166,6 +178,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * @param {SectionsService} sectionsService */ constructor( + private http: HttpClient, private authService: AuthService, private changeDetectorRef: ChangeDetectorRef, private halService: HALEndpointService, @@ -174,6 +187,21 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { this.isActive = true; } + ngOnInit() { + let currentUser$ = this.authService.getAuthenticatedUserFromStore(); + currentUser$.subscribe((eperson: EPerson) => { + if (eperson) { + this.http.get(eperson._links?.groups?.href).subscribe((responsePath: any) => { + const groups = responsePath._embedded?.groups || []; + this.isAuthorizedForPdfViewer = groups.some(group => + group.name === 'Administrator' || group.name === 'SeDiCIAdmin' + ); + this.changeDetectorRef.detectChanges(); + }); + } + }); + } + /** * Initialize all instance variables and retrieve form configuration */ @@ -227,6 +255,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { // start auto save this.submissionService.startAutoSave(this.submissionId); + this.loadSectionData(); } } @@ -310,4 +339,37 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { sections.filter((section: SectionDataObject) => !isEqual(section.sectionType,SectionsType.Collection))), ); } + + uploadFilesEvent(responsePath: any) { + const files = responsePath.sections.upload.files; + const pdfFiles = files.filter(file => file.format.extensions.includes("pdf")); // Filtro los archivos PDF + const lastFileLoad = files[files.length - 1]; + + // Lo cargo sólo si es el primer archivo PDF + if (pdfFiles.length === 1 && lastFileLoad.format.extensions.includes("pdf")) { + const fileUrl = lastFileLoad.url; + this.http.get(fileUrl, { responseType: 'blob' }).subscribe((blob: Blob) => { + this.pdfBlobUrl = URL.createObjectURL(blob); + this.changeDetectorRef.detectChanges(); + }); + } + } + + loadSectionData() { + this.submissionService.getSubmissionSections(this.submissionId).subscribe((sections: SectionDataObject[]) => { + const uploadSection = sections.find(section => section.sectionType === 'upload'); + if (uploadSection && uploadSection.data && (uploadSection.data as any).files && (uploadSection.data as any).files.length > 0) { + const files = (uploadSection.data as any).files; + const primaryUUID = (uploadSection.data as any).primary; + const pdfFiles = files.filter(file => file.format.extensions.includes("pdf")); // Filtro los archivos PDF + const pdfFile = pdfFiles.find(file => file.uuid === primaryUUID) || pdfFiles[0]; // Filtro el archivo PDF primario o el primero de la lista + if (pdfFile) { + const fileUrl = pdfFile.url; + this.http.get(fileUrl, { responseType: 'blob' }).subscribe((blob: Blob) => { + this.pdfBlobUrl = URL.createObjectURL(blob); + }); + } + } + }); + } } diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts index e1d933810e2..588463e76e6 100644 --- a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts @@ -4,6 +4,8 @@ import { Input, OnChanges, OnDestroy, + EventEmitter, + Output, } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { @@ -62,6 +64,8 @@ export class SubmissionUploadFilesComponent implements OnChanges, OnDestroy { */ @Input() uploadFilesOptions: UploaderOptions; + @Output() uploadFilesEvent = new EventEmitter(); + /** * A boolean representing if is possible to active drop zone over the document page * @type {boolean} @@ -170,6 +174,7 @@ export class SubmissionUploadFilesComponent implements OnChanges, OnDestroy { } }), ); + this.uploadFilesEvent.emit(workspaceitem); } /** diff --git a/src/app/submission/form/submission-upload-files/themed-submission-upload-files.component.ts b/src/app/submission/form/submission-upload-files/themed-submission-upload-files.component.ts index d26a402ba60..1cb58e723fe 100644 --- a/src/app/submission/form/submission-upload-files/themed-submission-upload-files.component.ts +++ b/src/app/submission/form/submission-upload-files/themed-submission-upload-files.component.ts @@ -1,6 +1,8 @@ import { Component, Input, + Output, + EventEmitter, } from '@angular/core'; import { ThemedComponent } from '../../../shared/theme-support/themed.component'; @@ -22,10 +24,13 @@ export class ThemedSubmissionUploadFilesComponent extends ThemedComponent(); + protected inAndOutputNames: (keyof SubmissionUploadFilesComponent & keyof this)[] = [ 'collectionId', 'submissionId', 'uploadFilesOptions', + 'uploadFilesEvent', ]; protected getComponentName(): string { diff --git a/src/app/submission/models/filter-config.model.ts b/src/app/submission/models/filter-config.model.ts new file mode 100644 index 00000000000..6ebe851c16d --- /dev/null +++ b/src/app/submission/models/filter-config.model.ts @@ -0,0 +1,29 @@ +export interface FilterInfo { + id: string; + label?: string; + icon?: string; + description: string; +} + +export class FilterConfig { + static readonly GENERAL_METADATA_FILTER: FilterInfo[] = [ + { id: 'upperCase', label: 'AA', description: 'upperCase\nPasa el texto completo a mayúsculas' }, + { id: 'lowerCase', label: 'aa', description: 'lowerCase\nPasa el texto completo a minúsculas' }, + { id: 'capitalize', label: 'Aa', description: 'Capitalize\nPasa la primera letra de cada oración a mayúscula' }, + { id: 'cleanText', label: 'Norm', description: 'cleanText\nHace lo mismo que el viejo norm.php' }, + { id: 'removeDoubleSpaces', label: '❌␣␣', description: 'Remove double spaces\nElimina los espacios dobles' }, + { id: 'removeSpacesBetweenLetters', icon: 'fa-solid fa-bars', description: 'Remove spaces between letters\nSaca espacios de donde no van (Ej: T I T U L O)' }, + { id: 'removeSpacesAtStartAndEnd', icon: 'fa-solid fa-bars', description: 'Remove spaces at start and end\nElimina los espacios al principio y al final del texto' }, + { id: 'removeLineBreaks', icon: 'fa-solid fa-bars', description: 'Remove line breaks\nElimina los saltos de línea' } + ]; + + static readonly REPEATABLE_METADATA_FILTER: FilterInfo[] = [ + { id: 'splitByDelimiter', label: 'Split', description: 'splitByDelimiter\nSe toma el texto completo como una lista y se separa en cada uno de sus elementos' }, + ]; + + static readonly PEOPLE_METADATA_FILTER: FilterInfo[] = [ + { id: 'reorderPerson', icon: 'fa-solid fa-right-left', description: 'Reorder person name\nReodena "Nombre/s Apellido/s" en "Apellido/s, Nombre/s"\nACLARACIÓN:\n*Si hay 3 palabras se toman 2 nombres y 1 apellido\n*Si hay 4 palabras se toman 2 nombres y 2 apellidos' }, + { id: 'removeTitles', label: '❌🎓', description: 'Remove titles\nElimina títulos o grados de las personas (Ej: Lic., Mg.)' }, + { id: 'removeReferences', label: '❌🔢', description: 'Remove references\nElimina referencias asociadas de las personas (Ej: números, asteriscos)' }, + ]; +} \ No newline at end of file diff --git a/src/app/submission/models/metadata-config.model.ts b/src/app/submission/models/metadata-config.model.ts new file mode 100644 index 00000000000..edf021d1629 --- /dev/null +++ b/src/app/submission/models/metadata-config.model.ts @@ -0,0 +1,87 @@ +export class MetadataConfig { + static readonly REPEATABLE_METADATA: string[] = [ + 'sedici_identifier_isbn', + 'sedici_identifier_issn', + 'sedici_identifier_other', + 'sedici_identifier_uri', + 'sedici_creator_person', + 'sedici_creator_corporate', + 'sedici_creator_interprete', + 'sedici_contributor_colaborator', + 'sedici_contributor_translator', + 'sedici_contributor_editor', + 'sedici_contributor_compiler', + 'sedici_contributor_director', + 'sedici_contributor_codirector', + 'sedici_contributor_juror', + 'sedici_contributor_inscriber', + 'dc_title_alternative', + 'dc_format', + 'dc_format_extent', + 'dc_subject', + 'sedici_subject_materias', + 'sedici_subject_ford', + 'sedici_institucionDesarrollo', + 'mods_originInfo_place', + 'mods_location', + 'sedici_relation_isRelatedWith', + 'dcterms_audience', + 'dc_coverage_spatial', + 'dc_coverage_temporal', + 'dc_description_filiation', + 'sedici_description_note', + 'dc_description_abstract', + ]; + + static readonly EXCLUDED_IDS: Set = new Set([ + 'selected-text', + 'dc_type', + 'sedici_subtype', + 'dc_language', + 'sedici_description_fulltext', + 'sedici_description_peerReview', + 'dc_date_issued_month', + 'dc_date_issued_day', + 'dc_date_created_month', + 'dc_date_created_day', + 'sedici_date_exposure_month', + 'sedici_date_exposure_day', + ]); + + static readonly EXCLUDED_IDS_FOR_BUTTONS: Set = new Set([ + 'selected-text', + 'dc_type', + 'sedici_subtype', + 'dc_language', + 'sedici_description_fulltext', + 'sedici_description_peerReview', + 'dc_date_issued_year', + 'dc_date_issued_month', + 'dc_date_issued_day', + 'dc_date_created_year', + 'dc_date_created_month', + 'dc_date_created_day', + 'sedici_date_exposure_year', + 'sedici_date_exposure_month', + 'sedici_date_exposure_day', + ]); + + static readonly NAME_MAP: Record = { + 'dc_date_issued_year': 'Fecha de publicación', + 'dc_date_created_year': 'Fecha de creación', + 'sedici_date_exposure_year': 'Fecha de presentación', + }; + + static readonly PEOPLE_METADATA: string[] = [ + 'sedici_creator_person', + 'sedici_creator_interprete', + 'sedici_contributor_colaborator', + 'sedici_contributor_translator', + 'sedici_contributor_editor', + 'sedici_contributor_compiler', + 'sedici_contributor_director', + 'sedici_contributor_codirector', + 'sedici_contributor_juror', + 'sedici_contributor_inscriber', + ]; +} \ No newline at end of file diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 6ef2fc51b84..565e38f12d1 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -368,7 +368,7 @@ export class SectionFormOperationsService { * @param hasStoredValue * representing if field value related to the specified operation has stored value */ - protected dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, + public dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, hasStoredValue: boolean): void { diff --git a/src/app/submission/sections/sections.directive.ts b/src/app/submission/sections/sections.directive.ts index 9d326997706..12a68920aac 100644 --- a/src/app/submission/sections/sections.directive.ts +++ b/src/app/submission/sections/sections.directive.ts @@ -148,9 +148,9 @@ export class SectionsDirective implements OnDestroy, OnInit { if (previousActive !== this.active) { this.changeDetectorRef.detectChanges(); // If section is no longer active dispatch save action - if (!this.active && isNotNull(activeSectionId)) { - this.submissionService.dispatchSave(this.submissionId); - } + // if (!this.active && isNotNull(activeSectionId)) { + // this.submissionService.dispatchSave(this.submissionId); + // } } }), ); diff --git a/src/app/submission/utils/content-splitters.ts b/src/app/submission/utils/content-splitters.ts new file mode 100644 index 00000000000..18e1f2e41e0 --- /dev/null +++ b/src/app/submission/utils/content-splitters.ts @@ -0,0 +1,180 @@ +export interface DateParts { + day: string; + month: string; + year: string; +} + +export class DateSplitter { + private readonly monthNames: Record = { + enero: '1', febrero: '2', marzo: '3', abril: '4', mayo: '5', junio: '6', + julio: '7', agosto: '8', septiembre: '9', octubre: '10', noviembre: '11', diciembre: '12', + january: '1', february: '2', march: '3', april: '4', may: '5', june: '6', + july: '7', august: '8', september: '9', october: '10', november: '11', december: '12' + }; + + split(date: string): DateParts { + let day = ''; + let month = ''; + let year = ''; + + const formats = this.getDateFormats(); + + for (const format of formats) { + const match = date.match(format.regex); + if (match) { + const result = format.handler(match); + day = result.day || ''; + month = result.month || ''; + year = result.year || ''; + break; + } + } + + return { day, month, year }; + } + + private getDateFormats(): Array<{ + regex: RegExp; + handler: (m: RegExpMatchArray) => Partial; + }> { + const monthNames = this.monthNames; + + return [ + { regex: /^(\d{4})$/, handler: (m) => ({ year: m[1] }) }, // AAAA + { regex: /^(\d{1,2})\/(\d{4})$/, handler: (m) => ({ month: m[1], year: m[2] }) }, // MM/AAAA + { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, handler: (m) => ({ day: m[1], month: m[2], year: m[3] }) }, // DD/MM/AAAA + { regex: /^(\d{4})\/(\d{1,2})$/, handler: (m) => ({ year: m[1], month: m[2] }) }, // AAAA/MM + { regex: /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/, handler: (m) => ({ year: m[1], month: m[2], day: m[3] }) }, // AAAA/MM/DD + { regex: /^([a-zA-Zñ]+)\s+de\s+(\d{4})$/, handler: (m) => ({ month: monthNames[m[1].toLowerCase()], year: m[2] }) }, // M de AAAA + { regex: /^([a-zA-Zñ]+)\s+(\d{4})$/, handler: (m) => ({ month: monthNames[m[1].toLowerCase()], year: m[2] }) }, // M AAAA + { regex: /^(\d{4})\s+([a-zA-Zñ]+)$/, handler: (m) => ({ month: monthNames[m[2].toLowerCase()], year: m[1] }) }, // AAAA M + { regex: /^([a-zA-Zñ]+),\s*(\d{4})$/, handler: (m) => ({ month: monthNames[m[1].toLowerCase()], year: m[2] }) }, // M, AAAA + { regex: /^([a-zA-Zñ]+)-([a-zA-Zñ]+)\s+(\d{4})$/, handler: (m) => ({ month: monthNames[m[2].toLowerCase()], year: m[3] }) }, // M-M AAAA + { regex: /^([a-zA-Zñ]+)\s+(\d{1,2}),\s*(\d{4})$/, handler: (m) => ({ month: monthNames[m[1].toLowerCase()], day: m[2], year: m[3] }) }, // M D, AAAA + { regex: /^(\d{1,2})\s+de\s+([a-zA-Zñ]+)\s+de\s+(\d{4})$/, handler: (m) => ({ day: m[1], month: monthNames[m[2].toLowerCase()], year: m[3] }) }, // D de M de AAAA + { regex: /^(\d{1,2})\s+([a-zA-Zñ]+)\s+de\s+(\d{4})$/, handler: (m) => ({ day: m[1], month: monthNames[m[2].toLowerCase()], year: m[3] }) }, // D M de AAAA + { regex: /^(\d{1,2})\s+([a-zA-Zñ]+)\s+(\d{4})$/, handler: (m) => ({ day: m[1], month: monthNames[m[2].toLowerCase()], year: m[3] }) }, // D M AAAA + { regex: /^(\d{1,2})\s+de\s+([a-zA-Zñ]+)\s+(\d{4})$/, handler: (m) => ({ day: m[1], month: monthNames[m[2].toLowerCase()], year: m[3] }) }, // D de M AAAA + { regex: /^(\d{1,2})\s+al\s+(\d{1,2})\s+de\s+([a-zA-Zñ]+)\s+de\s+(\d{4})$/, handler: (m) => ({ month: monthNames[m[3].toLowerCase()], year: m[4] }) }, // D1 al D2 de M de AAAA + { regex: /^(\d{1,2})-(\d{1,2})\s+de\s+([a-zA-Zñ]+)\s+de\s+(\d{4})$/, handler: (m) => ({ month: monthNames[m[3].toLowerCase()], year: m[4] }) }, // D1-D2 de M de AAAA + ]; + } +} + +export interface JournalParts { + year?: string; + volume?: string; + tomo?: string; + issue?: string; +} + +export class VolumeIssueSplitter { + split(data: string): JournalParts { + let result: JournalParts = {}; + + // Procesar patrones de número + this.processPatterns(data, this.getIssuePatterns(), result); + + // Procesar patrones de año + this.processPatterns(data, this.getYearPatterns(), result); + + // Procesar patrones de volumen + this.processPatterns(data, this.getVolumePatterns(), result); + + // Procesar patrones extras + if (!result.volume || !result.issue) { + this.processPatterns(data, this.getExtraPatterns(), result); + } + + return result; + } + + private processPatterns( + data: string, + patterns: Array<{ regex: RegExp; fields: string[] }>, + result: JournalParts + ): void { + for (const pattern of patterns) { + const match = data.match(pattern.regex); + if (match) { + pattern.fields.forEach((field, index) => { + const capturedValue = match.slice(index + 1).find(value => value !== undefined); + if (capturedValue && result[field] === undefined) { + if (field === 'tomo' && pattern.regex.toString().includes('IVXLCDM')) { + result[field] = this.romanToInt(match[0].toUpperCase()).toString(); + } else { + result[field] = field === 'year' ? capturedValue : parseInt(capturedValue, 10).toString(); + } + } + }); + break; + } + } + } + + private getIssuePatterns(): Array<{ regex: RegExp; fields: string[] }> { + return [ + { + regex: /(?:\bN[º°.\s]*\s*(\d+)|No\.\s*(\d+)|\((?:N[º°.\s]*?)?\s*(\d+)\)|N\.(\d+)|(?:Número|número|Number|number|Núm\.|núm\.)\s*(\d+)|\((\d+)\))/i, + fields: ['issue'], + } + ]; + } + + private getYearPatterns(): Array<{ regex: RegExp; fields: string[] }> { + return [ + { + regex: /(?:Año|año)?\s*(\d{4})/i, + fields: ['year'], + } + ]; + } + + private getVolumePatterns(): Array<{ regex: RegExp; fields: string[] }> { + return [ + { + regex: /(?:[Vv](?:ol(?:\.|umen)?|olume)?\.?\s*(\d+)|\b\w+,\s*(\d+))/i, + fields: ['volume'], + }, + { + regex: /\b[IVXLCDM]+\b/g, + fields: ['tomo'], + } + ]; + } + + private getExtraPatterns(): Array<{ regex: RegExp; fields: string[] }> { + return [ + { + regex: /(\d+)\.(\d+)/, + fields: ['issue', 'volume'], + }, + { + regex: /(\d+)\s?\((\d+)\)/, + fields: ['volume', 'issue'], + } + ]; + } + + private romanToInt(roman: string): number { + const romanNumeralMap: Record = { + I: 1, IV: 4, V: 5, IX: 9, X: 10, XL: 40, L: 50, + XC: 90, C: 100, CD: 400, D: 500, CM: 900, M: 1000 + }; + + let i = 0; + let num = 0; + + while (i < roman.length) { + if (i + 1 < roman.length && romanNumeralMap[roman.substring(i, i + 2)]) { + num += romanNumeralMap[roman.substring(i, i + 2)]; + i += 2; + } else { + num += romanNumeralMap[roman.charAt(i)]; + i += 1; + } + } + + return num; + } +} \ No newline at end of file diff --git a/src/themes/custom/app/header/header.component.html b/src/themes/custom/app/header/header.component.html index eefc2ee0615..5f8af25412e 100644 --- a/src/themes/custom/app/header/header.component.html +++ b/src/themes/custom/app/header/header.component.html @@ -1,4 +1,8 @@ -
+