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}}
-
+
+
+
+
+
+
(downloadZipMinFileSize | async) &&
diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.scss b/src/app/item-page/clarin-files-section/clarin-files-section.component.scss
index 7cdb21d54b6..499944649ae 100644
--- a/src/app/item-page/clarin-files-section/clarin-files-section.component.scss
+++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.scss
@@ -17,44 +17,17 @@ The styling file for the `clarin-files-section.component`
.command-pre {
display: block;
- padding: 9.5px;
- margin: 0 0 10px;
+ padding: 12px 16px;
+ margin: 0;
font-size: 13px;
- line-height: 1.428571429;
+ line-height: 1.5;
+ white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
background-color: #d9edf7;
color: #3a87ad;
border: 1px solid #ccc;
border-radius: 4px;
-}
-
-#command-div .repo-copy-btn {
- opacity: 0;
- -webkit-transition: opacity 0.3s ease-in-out;
- -o-transition: opacity 0.3s ease-in-out;
- transition: opacity 0.3s ease-in-out;
-}
-
-#command-div:hover .repo-copy-btn, #command-div .repo-copy-btn:focus {
- opacity: 1;
-}
-
-.repo-copy-btn {
- width: 20px;
- height: 20px;
- position: relative;
- display: inline-block;
- padding: 0 !important;
-}
-
-.repo-copy-btn:before {
- content: " ";
- background-size: 13px 15px;
- background-repeat: no-repeat;
- background-color: red;
- display: inline-block;
- width: 13px;
- height: 15px;
- padding: 0 !important;
+ max-height: 60vh;
+ overflow-y: auto;
}
diff --git a/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts b/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts
index a1455425ebf..39cfc62bd45 100644
--- a/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts
+++ b/src/app/item-page/clarin-files-section/clarin-files-section.component.spec.ts
@@ -14,31 +14,39 @@ import { ConfigurationDataService } from '../../core/data/configuration-data.ser
import { Item } from '../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
+import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
describe('ClarinFilesSectionComponent', () => {
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",