diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.html b/src/app/item-page/clarin-files-section/clarin-files-section.component.html index 09829decf58..c109c0cc29f 100644 --- a/src/app/item-page/clarin-files-section/clarin-files-section.component.html +++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.html @@ -3,15 +3,33 @@
 {{'item.page.files.head' | translate}}
-   {{'item.page.download.button.command.line' | translate}} -
-
{{ command }}
-
+ + + + + + { let component: ClarinFilesSectionComponent; let fixture: ComponentFixture; let mockRegistryService: any; - let halService: HALEndpointService; + let halService: any; + + const ROOT_HREF = 'http://localhost:8080/server/api'; + + function createMetadataBitstream(name: string, canPreview: boolean = true): MetadataBitstream { + const bs = new MetadataBitstream(); + bs.id = '70ccc608-f6a5-4c96-ab2d-53bc56ae8ebe'; + bs.name = name; + bs.description = 'test'; + bs.fileSize = 1024; + bs.checksum = 'abc'; + bs.type = new ResourceType('item'); + bs.fileInfo = []; + bs.format = 'text'; + bs.canPreview = canPreview; + bs._links = { + self: new HALLink(), + schema: new HALLink(), + }; + bs._links.self.href = ''; + bs._links.schema.href = ''; + return bs; + } + // Set up the mock service's getMetadataBitstream method to return a simple stream - const metadatabitstream = new MetadataBitstream(); - metadatabitstream.id = '70ccc608-f6a5-4c96-ab2d-53bc56ae8ebe'; - metadatabitstream.name = 'test'; - metadatabitstream.description = 'test'; - metadatabitstream.fileSize = 1024; - metadatabitstream.checksum = 'abc'; - metadatabitstream.type = new ResourceType('item'); - metadatabitstream.fileInfo = []; - metadatabitstream.format = 'text'; - metadatabitstream.canPreview = false; - metadatabitstream._links = { - self: new HALLink(), - schema: new HALLink(), - }; - - metadatabitstream._links.self.href = ''; - metadatabitstream._links.schema.href = ''; + const metadatabitstream = createMetadataBitstream('test', false); const metadataBitstreams: MetadataBitstream[] = [metadatabitstream]; const bitstreamStream = new BehaviorSubject(metadataBitstreams); @@ -63,12 +71,15 @@ describe('ClarinFilesSectionComponent', () => { 'getMetadataBitstream': of(bitstreamStream) } ); - halService = Object.assign(new HALEndpointServiceStub('some url')); + halService = Object.assign(new HALEndpointServiceStub('some url'), { + getRootHref: () => ROOT_HREF + }); await TestBed.configureTestingModule({ declarations: [ ClarinFilesSectionComponent ], imports: [ - TranslateModule.forRoot() + TranslateModule.forRoot(), + NgbModalModule, ], providers: [ { provide: RegistryService, useValue: mockRegistryService }, @@ -88,4 +99,122 @@ describe('ClarinFilesSectionComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('generateCurlCommand', () => { + const BASE = `${ROOT_HREF}/core/bitstreams/handle`; + + it('should generate a curl command for a single file', () => { + component.itemHandle = '123456789/1'; + component.listOfFiles.next([createMetadataBitstream('simple.txt')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "simple.txt" "${BASE}/123456789/1/simple.txt"` + ); + }); + + it('should generate a curl command for multiple files', () => { + component.itemHandle = '123456789/2'; + component.listOfFiles.next([ + createMetadataBitstream('file1.txt'), + createMetadataBitstream('file2.txt'), + ]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "file1.txt" "${BASE}/123456789/2/file1.txt" ` + + `-o "file2.txt" "${BASE}/123456789/2/file2.txt"` + ); + }); + + it('should percent-encode spaces in URL but keep real name in -o', () => { + component.itemHandle = '123456789/3'; + component.listOfFiles.next([createMetadataBitstream('my file.txt')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "my file.txt" "${BASE}/123456789/3/my%20file.txt"` + ); + }); + + it('should percent-encode parentheses in URL but keep real name in -o', () => { + component.itemHandle = '123456789/4'; + component.listOfFiles.next([createMetadataBitstream('logo (2).png')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "logo (2).png" "${BASE}/123456789/4/logo%20%282%29.png"` + ); + }); + + it('should percent-encode plus signs in URL', () => { + component.itemHandle = '123456789/5'; + component.listOfFiles.next([createMetadataBitstream('dtq+logo.png')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "dtq+logo.png" "${BASE}/123456789/5/dtq%2Blogo.png"` + ); + }); + + it('should handle mixed special characters in multiple files', () => { + component.itemHandle = '123456789/6'; + component.listOfFiles.next([ + createMetadataBitstream('dtq+logo (2).png'), + createMetadataBitstream('Screenshot 1.png'), + ]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "dtq+logo (2).png" "${BASE}/123456789/6/dtq%2Blogo%20%282%29.png" ` + + `-o "Screenshot 1.png" "${BASE}/123456789/6/Screenshot%201.png"` + ); + }); + + it('should preserve UTF-8 characters in -o filename and encode in URL', () => { + component.itemHandle = '123456789/9'; + component.listOfFiles.next([createMetadataBitstream('M\u00e9di\u00e1 (3).jfif')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "M\u00e9di\u00e1 (3).jfif" "${BASE}/123456789/9/M%C3%A9di%C3%A1%20%283%29.jfif"` + ); + }); + + it('should escape double quotes in filenames', () => { + component.itemHandle = '123456789/10'; + component.listOfFiles.next([createMetadataBitstream('file "quoted".txt')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "file \\"quoted\\".txt" "${BASE}/123456789/10/file%20%22quoted%22.txt"` + ); + }); + + it('should set canShowCurlDownload to true when any file canPreview', () => { + component.canShowCurlDownload = false; + component.itemHandle = '123456789/7'; + component.listOfFiles.next([createMetadataBitstream('file.txt', true)]); + component.generateCurlCommand(); + expect(component.canShowCurlDownload).toBeTrue(); + }); + + it('should not set canShowCurlDownload for non-previewable files', () => { + component.canShowCurlDownload = false; + component.itemHandle = '123456789/8'; + component.listOfFiles.next([createMetadataBitstream('file.txt', false)]); + component.generateCurlCommand(); + expect(component.canShowCurlDownload).toBeFalse(); + }); + + it('should handle filenames containing a literal percent sign', () => { + component.itemHandle = '123456789/11'; + component.listOfFiles.next([createMetadataBitstream('100% done.txt')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "100% done.txt" "${BASE}/123456789/11/100%25%20done.txt"` + ); + }); + + it('should handle complex filename with diacritics, plus, hash, and unmatched paren', () => { + component.itemHandle = '123456789/12'; + component.listOfFiles.next([createMetadataBitstream('M\u00e9di\u00e1 (+)#9) ano')]); + component.generateCurlCommand(); + expect(component.command).toBe( + `curl -o "M\u00e9di\u00e1 (+)#9) ano" "${BASE}/123456789/12/M%C3%A9di%C3%A1%20%28%2B%29%239%29%20ano"` + ); + }); + }); }); diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.ts b/src/app/item-page/clarin-files-section/clarin-files-section.component.ts index 08edfe7ac55..e95b0cd4c44 100644 --- a/src/app/item-page/clarin-files-section/clarin-files-section.component.ts +++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.ts @@ -8,6 +8,7 @@ import { Router } from '@angular/router'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; import { BehaviorSubject } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-clarin-files-section', @@ -29,9 +30,9 @@ export class ClarinFilesSectionComponent implements OnInit { canShowCurlDownload = false; /** - * If download by command button is click, the command line will be shown + * Whether the command was recently copied to clipboard */ - isCommandLineVisible = false; + commandCopied = false; /** * command for the download command feature @@ -75,7 +76,8 @@ export class ClarinFilesSectionComponent implements OnInit { constructor(protected registryService: RegistryService, protected router: Router, protected halService: HALEndpointService, - protected configurationService: ConfigurationDataService) { + protected configurationService: ConfigurationDataService, + protected modalService: NgbModal) { } ngOnInit(): void { @@ -90,8 +92,16 @@ export class ClarinFilesSectionComponent implements OnInit { this.loadDownloadZipConfigProperties(); } - setCommandline() { - this.isCommandLineVisible = !this.isCommandLineVisible; + openCommandModal(content: any) { + this.commandCopied = false; + this.modalService.open(content, { size: 'lg', centered: true }); + } + + copyCommand() { + navigator.clipboard.writeText(this.command).then(() => { + this.commandCopied = true; + setTimeout(() => this.commandCopied = false, 2000); + }); } downloadFiles() { @@ -107,10 +117,19 @@ export class ClarinFilesSectionComponent implements OnInit { return file.name; }); - // Generate curl command for individual bitstream downloads - const baseUrl = `${this.halService.getRootHref()}/bitstream/${this.itemHandle}`; - const fileNamesFormatted = fileNames.map((fileName, index) => `/${index}/${fileName}`).join(','); - this.command = `curl -O ${baseUrl}{${fileNamesFormatted}}`; + // Generate curl command with -o "filename" "url" pairs for each file. + // Each file needs its own -o + URL pair because curl URL globbing ({}) + // does NOT support per-file -o flags (multiple -o with {} results in + // "Got more output options than URLs" and only the first file is saved). + // Using -o lets the shell pass the real filename (including UTF-8) directly. + const baseUrl = `${this.halService.getRootHref()}/core/bitstreams/handle/${this.itemHandle}`; + const parts = fileNames.map(name => { + const encodedName = encodeURIComponent(name) + .replace(/[()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); + const safeName = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `-o "${safeName}" "${baseUrl}/${encodedName}"`; + }); + this.command = `curl ${parts.join(' ')}`; } loadDownloadZipConfigProperties() { diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 635847a464a..bc46ac9a259 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -9203,6 +9203,9 @@ // "item.page.download.button.command.line": "Download instructions for command line", "item.page.download.button.command.line": "Instrukce pro stažení z příkazové řádky", + "item.page.download.command.copy": "Kopírovat do schránky", + "item.page.download.command.copied": "Zkopírováno!", + "item.page.download.command.close": "Zavřít", // "item.page.download.button.all.files.zip": "Download all files in item", "item.page.download.button.all.files.zip": "Stáhnout všechny soubory záznamu", diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index 041295408d5..b1ab4b24764 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -9308,6 +9308,9 @@ // "item.page.download.button.command.line": "Download instructions for command line", // TODO New key - Add a translation "item.page.download.button.command.line": "Download instructions for command line", + "item.page.download.command.copy": "Copy to clipboard", + "item.page.download.command.copied": "Copied!", + "item.page.download.command.close": "Close", // "item.page.download.button.all.files.zip": "Download all files in item", // TODO New key - Add a translation diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 77e9cb4497d..f2460aa3969 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -6099,6 +6099,9 @@ "item.page.files.head": "Files in this item", "item.page.download.button.command.line": "Download instructions for command line", + "item.page.download.command.copy": "Copy to clipboard", + "item.page.download.command.copied": "Copied!", + "item.page.download.command.close": "Close", "item.page.download.button.all.files.zip": "Download all files in item",