From bc0349b78db3f8a4769ad065696e555442eb0e3d Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Wed, 27 May 2026 13:02:57 -0400 Subject: [PATCH 1/9] 141151: Adjust cc license section to expect i18n tags, cc license dropdown menu increase in size (cherry picked from commit d21385b4556f72dfc1372167f567d75542725d19) --- ...bmission-section-cc-licenses.component.html | 18 +++++++++--------- ...bmission-section-cc-licenses.component.scss | 7 +++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html index 0f79fa6a1dd..b59fddc846e 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.html @@ -43,7 +43,7 @@
- {{ field.label }} + {{ field.label | translate }}
@@ -86,7 +86,7 @@
- {{ option.label }} + {{ option.label | translate }} {{ 'submission.sections.ccLicense.option.select' | translate }} @@ -97,7 +97,7 @@
@@ -108,10 +108,10 @@
class="d-flex flex-row m-1">
- {{ option.label }} + {{ option.label | translate }}
diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss index 142cd82822a..108c5b78595 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss @@ -3,8 +3,11 @@ } .ccLicense-select { - width: fit-content; -} + width: 400px; + ngb-dropdown, .input-group, input.form-control { + width: 100%; + } + } .scrollable-menu { height: auto; From 6bfc63506064d83d3df7189f9814bda0e2160327 Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Tue, 27 Oct 2026 17:05:29 -0400 Subject: [PATCH 2/9] 141151: Adjusted CC license section to restore its previous state, this allows for adjusting the license based on what was previously provided between saves, fixed erroneous i18n message (cherry picked from commit 20a50c1edacfa5ebb7a9e7b63f1008b489bcc4ef) --- ...mission-section-cc-licenses.component.scss | 10 +- ...sion-section-cc-licenses.component.spec.ts | 71 ++++++++++++++ ...ubmission-section-cc-licenses.component.ts | 96 +++++++++++++++++-- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss index 108c5b78595..eef804bac93 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.scss @@ -3,11 +3,13 @@ } .ccLicense-select { - width: 400px; - ngb-dropdown, .input-group, input.form-control { - width: 100%; - } + width: 400px; + max-width: 100%; + + ngb-dropdown, .input-group, input.form-control { + width: 100%; } +} .scrollable-menu { height: auto; diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index e59cf69ca48..5187a150360 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -281,4 +281,75 @@ describe('SubmissionSectionCcLicensesComponent', () => { }); }); }); + + describe('when a license uri is stored', () => { + + const enumOption = (id: string) => ({ id, label: id, description: id }); + const _links = { self: { href: 'test link' } }; + const cc30: SubmissionCcLicence = { + id: 'cc30', + type: SUBMISSION_CC_LICENSE, + name: 'Creative Commons License (3.0)', + fields: [ + { id: 'commercial', label: 'commercial', description: '', enums: [enumOption('y'), enumOption('n')] }, + { id: 'derivatives', label: 'derivatives', description: '', enums: [enumOption('y'), enumOption('sa'), enumOption('n')] }, + { id: 'jurisdiction', label: 'jurisdiction', description: '', enums: [enumOption(''), enumOption('us')] }, + ], + _links, + }; + const cc40: SubmissionCcLicence = { + id: 'cc40', + type: SUBMISSION_CC_LICENSE, + name: 'Creative Commons License (4.0)', + fields: [ + { id: 'commercial', label: 'commercial', description: '', enums: [enumOption('y'), enumOption('n')] }, + { id: 'derivatives', label: 'derivatives', description: '', enums: [enumOption('y'), enumOption('sa'), enumOption('n')] }, + ], + _links, + }; + const cc0: SubmissionCcLicence = { id: 'cc0', type: SUBMISSION_CC_LICENSE, name: 'CC0 1.0 Universal', fields: [], _links }; + const mark: SubmissionCcLicence = { id: 'mark', type: SUBMISSION_CC_LICENSE, name: 'Public Domain Mark 1.0 Universal', fields: [], _links }; + + const restore = (uri: string) => { + component.submissionCcLicenses = [cc30, cc40, cc0, mark]; + component.sectionData.data = { uri }; + (component as any).restoreSelectionFromStoredUri(); + }; + + it('should restore a CC 4.0 license with its options', () => { + restore('https://creativecommons.org/licenses/by-nc-sa/4.0/'); + expect(component.data.ccLicense.id).toEqual('cc40'); + expect(component.data.ccLicense.fields.commercial.id).toEqual('n'); + expect(component.data.ccLicense.fields.derivatives.id).toEqual('sa'); + expect(component.data.uri).toEqual('https://creativecommons.org/licenses/by-nc-sa/4.0/'); + }); + + it('should restore a CC 3.0 license with its jurisdiction', () => { + restore('https://creativecommons.org/licenses/by-nd/3.0/us/'); + expect(component.data.ccLicense.id).toEqual('cc30'); + expect(component.data.ccLicense.fields.commercial.id).toEqual('y'); + expect(component.data.ccLicense.fields.derivatives.id).toEqual('n'); + expect(component.data.ccLicense.fields.jurisdiction.id).toEqual('us'); + }); + + it('should distinguish public domain licenses with the same version', () => { + restore('https://creativecommons.org/publicdomain/mark/1.0/'); + expect(component.data.ccLicense.id).toEqual('mark'); + restore('https://creativecommons.org/publicdomain/zero/1.0/'); + expect(component.data.ccLicense.id).toEqual('cc0'); + }); + + it('should leave an unknown uri untouched', () => { + restore('https://example.org/my-license'); + expect(component.data.ccLicense).toBeUndefined(); + expect(component.accepted).toBeTrue(); + }); + + it('should not override an existing selection', () => { + component.submissionCcLicenses = [cc30, cc40, cc0, mark]; + component.sectionData.data = { uri: 'https://creativecommons.org/licenses/by/4.0/', ccLicense: { id: 'cc0', fields: {} } }; + (component as any).restoreSelectionFromStoredUri(); + expect(component.data.ccLicense.id).toEqual('cc0'); + }); + }); }); diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index b999db5d339..ed4e0c9e7c2 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -238,13 +238,10 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent * Get the link to the Creative Commons license corresponding with the selected options. */ getCcLicenseLink$(): Observable { - - if (!!this.storedCcLicenseLink) { - return observableOf(this.storedCcLicenseLink); - } if (!this.getSelectedCcLicense() || this.getSelectedCcLicense().fields.some( (field) => !this.getSelectedOption(this.getSelectedCcLicense(), field))) { - return undefined; + // Fall back to the stored link when the selection could not be (fully) restored from it + return this.storedCcLicenseLink ? observableOf(this.storedCcLicenseLink) : undefined; } const selectedCcLicense = this.getSelectedCcLicense(); return this.submissionCcLicenseUrlDataService.getCcLicenseLink( @@ -331,7 +328,16 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent this.operationsBuilder.remove(path); } } - this.sectionData.data = data; + const previousCcLicense = this.data?.ccLicense; + this.sectionData.data = { + ...data, + // The REST API only returns the stored uri, so keep the (restored) selection when it is missing + ccLicense: data.ccLicense ?? previousCcLicense, + }; + // The selection is updated asynchronously, so recompute the link once it has landed in the store + if (JSON.stringify(previousCcLicense) !== JSON.stringify(this.data.ccLicense)) { + this.ccLicenseLinkTrigger$.next(); + } }), this.configService.findByPropertyName('cc.license.jurisdiction').pipe( getFirstCompletedRemoteData(), @@ -387,9 +393,87 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent (licenses) => { this.submissionCcLicenses = [...this.submissionCcLicenses, ...licenses]; this.isLoading = false; + this.restoreSelectionFromStoredUri(); this.ref.detectChanges(); }, ), ); } + + /** + * Rebuild the license selection (license + field answers) from the stored license uri. + * The REST API only returns the uri of a saved license, so without this the previously chosen + * license and options can't be shown or changed after the submission has been saved and reopened. + * Does not touch the uri or accepted state, as the stored license remains valid. + */ + private restoreSelectionFromStoredUri(): void { + if (hasValue(this.data?.ccLicense) || !this.storedCcLicenseLink) { + return; + } + const parsed = this.parseCcLicenseUri(this.storedCcLicenseLink); + if (!parsed) { + return; + } + const license = this.submissionCcLicenses.find((ccLicense) => parsed.matches(ccLicense.name ?? '')); + if (!license) { + // The license might be on a page that hasn't been loaded yet + return; + } + + const fields: { [fieldId: string]: Option } = {}; + (license.fields ?? []).forEach((field) => { + let optionId: string; + switch (field.id) { + case 'commercial': + optionId = parsed.unit.includes('nc') ? 'n' : 'y'; + break; + case 'derivatives': + optionId = parsed.unit.includes('nd') ? 'n' : parsed.unit.includes('sa') ? 'sa' : 'y'; + break; + case 'jurisdiction': + optionId = parsed.jurisdiction ?? ''; + break; + } + const option = field.enums?.find((o) => o.id === optionId); + if (option) { + fields[field.id] = option; + } + }); + + this.selectedCcLicense = license; + this.updateSectionData({ + ccLicense: { + id: license.id, + fields, + }, + }); + } + + /** + * Parse a creativecommons.org license uri into its unit (e.g. 'by-nc-nd', 'zero'), version and + * optional jurisdiction, together with a matcher for the name of the corresponding license. + * Returns null for uris that don't match a known Creative Commons pattern. + * @param uri the stored license uri + */ + private parseCcLicenseUri(uri: string): { unit: string, jurisdiction?: string, matches: (name: string) => boolean } { + const standard = /creativecommons\.org\/licenses\/([a-z-]+)\/(\d+\.\d+)(?:\/([a-z_-]+))?/i.exec(uri); + if (standard) { + const version = standard[2]; + return { + unit: standard[1].toLowerCase(), + jurisdiction: standard[3]?.toLowerCase(), + matches: (name) => name.includes(`(${version})`), + }; + } + const publicDomain = /creativecommons\.org\/publicdomain\/(zero|mark|certification)\//i.exec(uri); + if (publicDomain) { + const unit = publicDomain[1].toLowerCase(); + const namePart = { zero: 'cc0', mark: 'mark', certification: 'certification' }[unit]; + return { + unit, + matches: (name) => name.toLowerCase().includes(namePart), + }; + } + return null; + } } From a80b9119a6e10d8f0dad98bbbc44eaad77e06e06 Mon Sep 17 00:00:00 2001 From: Nathan Buckingham Date: Thu, 29 Oct 2026 13:55:50 -0400 Subject: [PATCH 3/9] 141151: Fix duplicate CC license remove on reselect and add CC option translations for recombo (cherry picked from commit 250d6bab9683eadb92b6a76032d25d707db3203c) --- ...sion-section-cc-licenses.component.spec.ts | 58 ++++++++++++++++++- ...ubmission-section-cc-licenses.component.ts | 12 +++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index 5187a150360..c304ef03a05 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { SubmissionSectionCcLicensesComponent } from './submission-section-cc-licenses.component'; import { SUBMISSION_CC_LICENSE } from '../../../core/submission/models/submission-cc-licence.resource-type'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, Subject } from 'rxjs'; import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; import { DebugElement } from '@angular/core'; import { By } from '@angular/platform-browser'; @@ -139,9 +139,11 @@ describe('SubmissionSectionCcLicensesComponent', () => { getCcLicenseLink: observableOf('test cc license link'), }); + let sectionState$: Subject; + const sectionService = { getSectionState: () => { - return observableOf({}); + return sectionState$; }, setSectionStatus: () => undefined, updateSectionData: (submissionId, sectionId, updatedData) => { @@ -186,6 +188,7 @@ describe('SubmissionSectionCcLicensesComponent', () => { })); beforeEach(() => { + sectionState$ = new Subject(); fixture = TestBed.createComponent(SubmissionSectionCcLicensesComponent); component = fixture.componentInstance; de = fixture.debugElement; @@ -309,9 +312,18 @@ describe('SubmissionSectionCcLicensesComponent', () => { }; const cc0: SubmissionCcLicence = { id: 'cc0', type: SUBMISSION_CC_LICENSE, name: 'CC0 1.0 Universal', fields: [], _links }; const mark: SubmissionCcLicence = { id: 'mark', type: SUBMISSION_CC_LICENSE, name: 'Public Domain Mark 1.0 Universal', fields: [], _links }; + const sampling: SubmissionCcLicence = { + id: 'sampling', + type: SUBMISSION_CC_LICENSE, + name: 'Creative Commons Sampling License (1.0)', + fields: [ + { id: 'sampling', label: 'sampling', description: '', enums: [enumOption('sampling'), enumOption('samplingplus'), enumOption('ncsamplingplus')] }, + ], + _links, + }; const restore = (uri: string) => { - component.submissionCcLicenses = [cc30, cc40, cc0, mark]; + component.submissionCcLicenses = [cc30, cc40, cc0, mark, sampling]; component.sectionData.data = { uri }; (component as any).restoreSelectionFromStoredUri(); }; @@ -332,6 +344,12 @@ describe('SubmissionSectionCcLicensesComponent', () => { expect(component.data.ccLicense.fields.jurisdiction.id).toEqual('us'); }); + it('should restore a Sampling license with its option', () => { + restore('https://creativecommons.org/licenses/nc-sampling+/1.0/'); + expect(component.data.ccLicense.id).toEqual('sampling'); + expect(component.data.ccLicense.fields.sampling.id).toEqual('ncsamplingplus'); + }); + it('should distinguish public domain licenses with the same version', () => { restore('https://creativecommons.org/publicdomain/mark/1.0/'); expect(component.data.ccLicense.id).toEqual('mark'); @@ -352,4 +370,38 @@ describe('SubmissionSectionCcLicensesComponent', () => { expect(component.data.ccLicense.id).toEqual('cc0'); }); }); + + describe('when the section state changes', () => { + const uri = 'https://creativecommons.org/licenses/by/4.0/'; + + beforeEach(() => { + operationsBuilder.add.calls.reset(); + operationsBuilder.remove.calls.reset(); + }); + + it('should queue a remove when the stored license is no longer accepted', () => { + component.sectionData.data = { uri }; + sectionState$.next({ data: { uri, accepted: false } }); + expect(operationsBuilder.remove).toHaveBeenCalledTimes(1); + }); + + it('should not queue a remove for a save response of an already removed license', () => { + component.sectionData.data = { uri, accepted: false }; + // The REST API returns the cleared section data without an accepted state + sectionState$.next({ data: { uri: null, rights: null, file: null } }); + expect(operationsBuilder.remove).not.toHaveBeenCalled(); + expect(component.accepted).toBeFalse(); + }); + + it('should queue a single remove when another license is selected', () => { + // Like the store, only apply the update once it is emitted by the section state + let updatedData; + spyOn(sectionService, 'updateSectionData').and.callFake((submissionId, sectionId, data) => updatedData = data); + component.submissionCcLicenses = submissionCcLicenses; + component.sectionData.data = { uri }; + component.selectCcLicense(submissionCcLicenses[0]); + sectionState$.next({ data: updatedData }); + expect(operationsBuilder.remove).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index ed4e0c9e7c2..95949fb3d3f 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -167,14 +167,16 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent return; } this.selectedCcLicense = ccLicense; - this.setAccepted(false); + // A single update, as separate updates would each be compared against stale section data this.updateSectionData({ ccLicense: { id: ccLicense.id, fields: {}, }, uri: undefined, + accepted: false, }); + this.updateSectionStatus(); // Trigger the debounced license link update this.ccLicenseLinkTrigger$.next(); } @@ -433,6 +435,10 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent case 'jurisdiction': optionId = parsed.jurisdiction ?? ''; break; + case 'sampling': + // e.g. 'nc-sampling+' -> 'ncsamplingplus' + optionId = parsed.unit.replace('-', '').replace('+', 'plus'); + break; } const option = field.enums?.find((o) => o.id === optionId); if (option) { @@ -450,13 +456,13 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent } /** - * Parse a creativecommons.org license uri into its unit (e.g. 'by-nc-nd', 'zero'), version and + * Parse a creativecommons.org license uri into its unit (e.g. 'by-nc-nd', 'sampling+', 'zero'), version and * optional jurisdiction, together with a matcher for the name of the corresponding license. * Returns null for uris that don't match a known Creative Commons pattern. * @param uri the stored license uri */ private parseCcLicenseUri(uri: string): { unit: string, jurisdiction?: string, matches: (name: string) => boolean } { - const standard = /creativecommons\.org\/licenses\/([a-z-]+)\/(\d+\.\d+)(?:\/([a-z_-]+))?/i.exec(uri); + const standard = /creativecommons\.org\/licenses\/([a-z+-]+)\/(\d+\.\d+)(?:\/([a-z_-]+))?/i.exec(uri); if (standard) { const version = standard[2]; return { From c8f6da7167a8b79ea11a5e68c816f147ebb7f578 Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 10:03:55 +0100 Subject: [PATCH 4/9] VSB-TUO: add the CC licence i18n keys from DSpace/dspace-angular#5735 The cherry-picked commits could not apply their en.json5 hunks onto the customer file, so the final set of keys from the PR head (75623449c6) is appended here in one go. Co-Authored-By: Claude Opus 5.5 --- src/assets/i18n/en.json5 | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index e24bf4378ca..a38f69f43c2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -6437,4 +6437,42 @@ "clarin-license-agreement-page.download-error": "An error occurred while downloading the file. Please try again later.", "footer.link.university": "University Library", + + "cc-license.commercial.label": "Allow commercial uses of your work?", + "cc-license.commercial.hint": "Choose whether others may use your work for commercial purposes.", + + "cc-license.commercial.option.yes.label": "Yes", + "cc-license.commercial.option.yes.hint": "Visitors may copy, distribute, display and perform your Submission for any purpose, including commercial purposes.", + + "cc-license.commercial.option.no.label": "No", + "cc-license.commercial.option.no.hint": "Visitors may copy, distribute, display and perform your Submission, but only for non-commercial purposes.", + + "cc-license.derivatives.label": "Allow modifications of your work?", + "cc-license.derivatives.hint": "Choose whether others may create derivative works based on your Submission.", + + "cc-license.derivatives.option.yes.label": "Yes", + "cc-license.derivatives.option.yes.hint": "Visitors may copy, distribute, display, perform and modify your Submission in any way.", + + "cc-license.derivatives.option.sharealike.label": "ShareAlike", + "cc-license.derivatives.option.sharealike.hint": "Visitors are permitted to copy, display, perform and modify your Submission, as long as they distribute the modified version on similar use terms.", + + "cc-license.derivatives.option.no.label": "No", + "cc-license.derivatives.option.no.hint": "Visitors are only permitted to copy and distribute unaltered versions of your Submission.", + + "cc-license.jurisdiction.label": "Jurisdiction", + "cc-license.jurisdiction.hint": "Select the legal jurisdiction for your Creative Commons license, or leave as the default international license.", + + "cc-license.jurisdiction.option.unported.label": "Generic (Unported)", + + "cc-license.sampling.label": "Which Sampling license would you like to use?", + "cc-license.sampling.hint": "Choose which kind of sampling, remixing or mash-up of your work you allow.", + + "cc-license.sampling.option.sampling.label": "Sampling", + "cc-license.sampling.option.sampling.hint": "Visitors may sample, remix or transform parts of your work for any purpose other than advertising, but may not copy or distribute the entire work.", + + "cc-license.sampling.option.samplingplus.label": "Sampling Plus", + "cc-license.sampling.option.samplingplus.hint": "Visitors may sample, remix or transform parts of your work for any purpose other than advertising, and may also copy and distribute the entire work for non-commercial purposes.", + + "cc-license.sampling.option.ncsamplingplus.label": "NonCommercial Sampling Plus", + "cc-license.sampling.option.ncsamplingplus.hint": "Visitors may sample, remix or transform parts of your work, and copy and distribute the entire work, but only for non-commercial purposes.", } From 6473b2599c120860093677261f70671af990bbc4 Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 10:04:48 +0100 Subject: [PATCH 5/9] VSB-TUO: Czech translations for the CC licence i18n keys The backend no longer localises the CC chooser (cc.license.locale is gone), so the labels and hints now come from the UI translation files. Co-Authored-By: Claude Opus 5.5 --- src/assets/i18n/cs.json5 | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index b9b7081b238..026a8df41e1 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -6948,6 +6948,81 @@ // "submission.sections.ccLicense.confirmation": "I grant the license above", "submission.sections.ccLicense.confirmation": "Uděluji výše uvedenou licenci", + // "cc-license.commercial.label": "Allow commercial uses of your work?", + "cc-license.commercial.label": "Povolit komerční užití vašeho díla?", + + // "cc-license.commercial.hint": "Choose whether others may use your work for commercial purposes.", + "cc-license.commercial.hint": "Zvolte, zda mohou ostatní užívat vaše dílo pro komerční účely.", + + // "cc-license.commercial.option.yes.label": "Yes", + "cc-license.commercial.option.yes.label": "Ano", + + // "cc-license.commercial.option.yes.hint": "Visitors may copy, distribute, display and perform your Submission for any purpose, including commercial purposes.", + "cc-license.commercial.option.yes.hint": "Návštěvníci mohou váš příspěvek kopírovat, šířit, vystavovat a předvádět za jakýmkoli účelem, včetně komerčního.", + + // "cc-license.commercial.option.no.label": "No", + "cc-license.commercial.option.no.label": "Ne", + + // "cc-license.commercial.option.no.hint": "Visitors may copy, distribute, display and perform your Submission, but only for non-commercial purposes.", + "cc-license.commercial.option.no.hint": "Návštěvníci mohou váš příspěvek kopírovat, šířit, vystavovat a předvádět, ale pouze pro nekomerční účely.", + + // "cc-license.derivatives.label": "Allow modifications of your work?", + "cc-license.derivatives.label": "Povolit úpravy vašeho díla?", + + // "cc-license.derivatives.hint": "Choose whether others may create derivative works based on your Submission.", + "cc-license.derivatives.hint": "Zvolte, zda mohou ostatní vytvářet odvozená díla na základě vašeho příspěvku.", + + // "cc-license.derivatives.option.yes.label": "Yes", + "cc-license.derivatives.option.yes.label": "Ano", + + // "cc-license.derivatives.option.yes.hint": "Visitors may copy, distribute, display, perform and modify your Submission in any way.", + "cc-license.derivatives.option.yes.hint": "Návštěvníci mohou váš příspěvek kopírovat, šířit, vystavovat, předvádět a jakkoli upravovat.", + + // "cc-license.derivatives.option.sharealike.label": "ShareAlike", + "cc-license.derivatives.option.sharealike.label": "Zachovejte licenci (ShareAlike)", + + // "cc-license.derivatives.option.sharealike.hint": "Visitors are permitted to copy, display, perform and modify your Submission, as long as they distribute the modified version on similar use terms.", + "cc-license.derivatives.option.sharealike.hint": "Návštěvníci mohou váš příspěvek kopírovat, vystavovat, předvádět a upravovat, pokud upravenou verzi šíří za stejných licenčních podmínek.", + + // "cc-license.derivatives.option.no.label": "No", + "cc-license.derivatives.option.no.label": "Ne", + + // "cc-license.derivatives.option.no.hint": "Visitors are only permitted to copy and distribute unaltered versions of your Submission.", + "cc-license.derivatives.option.no.hint": "Návštěvníci mohou váš příspěvek pouze kopírovat a šířit v nezměněné podobě.", + + // "cc-license.jurisdiction.label": "Jurisdiction", + "cc-license.jurisdiction.label": "Jurisdikce", + + // "cc-license.jurisdiction.hint": "Select the legal jurisdiction for your Creative Commons license, or leave as the default international license.", + "cc-license.jurisdiction.hint": "Vyberte právní jurisdikci licence Creative Commons, nebo ponechte výchozí mezinárodní licenci.", + + // "cc-license.jurisdiction.option.unported.label": "Generic (Unported)", + "cc-license.jurisdiction.option.unported.label": "Obecná (mezinárodní)", + + // "cc-license.sampling.label": "Which Sampling license would you like to use?", + "cc-license.sampling.label": "Kterou licenci Sampling chcete použít?", + + // "cc-license.sampling.hint": "Choose which kind of sampling, remixing or mash-up of your work you allow.", + "cc-license.sampling.hint": "Zvolte, jaký druh samplování, remixování nebo mash-upu vašeho díla povolujete.", + + // "cc-license.sampling.option.sampling.label": "Sampling", + "cc-license.sampling.option.sampling.label": "Sampling", + + // "cc-license.sampling.option.sampling.hint": "Visitors may sample, remix or transform parts of your work for any purpose other than advertising, but may not copy or distribute the entire work.", + "cc-license.sampling.option.sampling.hint": "Návštěvníci mohou části vašeho díla samplovat, remixovat nebo upravovat za jakýmkoli účelem kromě reklamy, ale nesmí kopírovat ani šířit celé dílo.", + + // "cc-license.sampling.option.samplingplus.label": "Sampling Plus", + "cc-license.sampling.option.samplingplus.label": "Sampling Plus", + + // "cc-license.sampling.option.samplingplus.hint": "Visitors may sample, remix or transform parts of your work for any purpose other than advertising, and may also copy and distribute the entire work for non-commercial purposes.", + "cc-license.sampling.option.samplingplus.hint": "Návštěvníci mohou části vašeho díla samplovat, remixovat nebo upravovat za jakýmkoli účelem kromě reklamy a celé dílo mohou kopírovat a šířit pro nekomerční účely.", + + // "cc-license.sampling.option.ncsamplingplus.label": "NonCommercial Sampling Plus", + "cc-license.sampling.option.ncsamplingplus.label": "Nekomerční Sampling Plus", + + // "cc-license.sampling.option.ncsamplingplus.hint": "Visitors may sample, remix or transform parts of your work, and copy and distribute the entire work, but only for non-commercial purposes.", + "cc-license.sampling.option.ncsamplingplus.hint": "Návštěvníci mohou části vašeho díla samplovat, remixovat nebo upravovat a celé dílo kopírovat a šířit, ale pouze pro nekomerční účely.", + // "submission.sections.general.add-more": "Add more", "submission.sections.general.add-more": "Přidat další", From 5f34e7777b2b83d585838f2a43231b8f510d4756 Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 12:50:33 +0100 Subject: [PATCH 6/9] VSB-TUO: queue the CC licence remove for a saved licence too The REST API never returns the accepted state, so after a save or reopen this.data.accepted is undefined and the VSB guard (wasAccepted) never let the remove through: unticking the licence, or picking another option, left the old dc.rights.uri on the item. Take the upstream condition from DSpace/dspace-angular#5735 (250d6bab96) and keep the VSB link filter on add. Co-Authored-By: Claude Opus 5.5 --- .../submission-section-cc-licenses.component.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index 95949fb3d3f..e89b2180e5a 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -308,13 +308,13 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent }), map((sectionState) => sectionState.data as WorkspaceitemSectionCcLicenseObject), ).subscribe((data) => { - const wasAccepted = this.data.accepted; const wasUri = this.data.uri; - // Only process if acceptance state actually changed - if (wasAccepted !== data.accepted && data.accepted !== undefined) { + // The REST API never returns the accepted state, so only changes made in this section (which always + // set it) should queue a patch. Otherwise a save response would queue a remove of an already removed uri. + if (hasValue(data.accepted) && this.data.accepted !== data.accepted) { const path = this.pathCombiner.getPath('uri'); - if (data.accepted && !wasAccepted) { + if (data.accepted) { // Only add URI if we're switching from not accepted to accepted const licenseLink$ = this.getCcLicenseLink$(); if (licenseLink$) { @@ -325,8 +325,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent this.operationsBuilder.add(path, link.toString(), false, true); }); } - } else if (!data.accepted && wasAccepted && !!this.data.uri) { - // Only remove URI if we're switching from accepted to not accepted + } else if (this.data.uri) { this.operationsBuilder.remove(path); } } From a8366a6de6a9a6a3c5652f55181a01631291e6eb Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 12:50:34 +0100 Subject: [PATCH 7/9] VSB-TUO: reword the Czech ShareAlike option of the CC licence step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is an answer to 'Povolit úpravy vašeho díla?', so lead with 'Ano'. Co-Authored-By: Claude Opus 5.5 --- src/assets/i18n/cs.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 026a8df41e1..83e51685f5b 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -6979,7 +6979,7 @@ "cc-license.derivatives.option.yes.hint": "Návštěvníci mohou váš příspěvek kopírovat, šířit, vystavovat, předvádět a jakkoli upravovat.", // "cc-license.derivatives.option.sharealike.label": "ShareAlike", - "cc-license.derivatives.option.sharealike.label": "Zachovejte licenci (ShareAlike)", + "cc-license.derivatives.option.sharealike.label": "Ano, se zachováním licence (ShareAlike)", // "cc-license.derivatives.option.sharealike.hint": "Visitors are permitted to copy, display, perform and modify your Submission, as long as they distribute the modified version on similar use terms.", "cc-license.derivatives.option.sharealike.hint": "Návštěvníci mohou váš příspěvek kopírovat, vystavovat, předvádět a upravovat, pokud upravenou verzi šíří za stejných licenčních podmínek.", From c0eb4284da043b78ad7de1096ab0b7132e799f4c Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 13:23:44 +0100 Subject: [PATCH 8/9] VSB-TUO: keep a saved CC licence that is unticked and ticked again Unticking a saved licence queues a remove, but data.uri still holds the stored link, so ticking it again was dropped by the #1009 duplicate-add filter and the save removed the licence the UI showed as accepted. Let the add through while a remove of the stored uri is pending. Co-Authored-By: Claude Opus 5.5 --- ...ission-section-cc-licenses.component.spec.ts | 17 +++++++++++++++++ .../submission-section-cc-licenses.component.ts | 15 ++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index c304ef03a05..3c12c0e380c 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -385,6 +385,23 @@ describe('SubmissionSectionCcLicensesComponent', () => { expect(operationsBuilder.remove).toHaveBeenCalledTimes(1); }); + it('should queue the stored license again when it is unticked and ticked again before saving', () => { + component.sectionData.data = { uri }; + sectionState$.next({ data: { uri, accepted: false } }); + sectionState$.next({ data: { uri, accepted: true } }); + expect(operationsBuilder.remove).toHaveBeenCalledTimes(1); + expect(operationsBuilder.add).toHaveBeenCalledTimes(1); + }); + + it('should not queue a duplicate add for a license the server already stores', () => { + component.sectionData.data = { uri }; + sectionState$.next({ data: { uri, accepted: false } }); + // Section data from the server, without an accepted state, ends the pending remove + sectionState$.next({ data: { uri } }); + sectionState$.next({ data: { uri, accepted: true } }); + expect(operationsBuilder.add).not.toHaveBeenCalled(); + }); + it('should not queue a remove for a save response of an already removed license', () => { component.sectionData.data = { uri, accepted: false }; // The REST API returns the cleared section data without an accepted state diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index e89b2180e5a..816414f2be4 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -94,6 +94,12 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent */ private ccLicenseLinkTrigger$ = new Subject(); + /** + * Whether a remove of the stored uri is queued but not yet saved, so accepting the same license again + * has to queue an add instead of being skipped as unchanged. + */ + private uriRemoveQueued = false; + /** * The Creative Commons link saved in the workspace item. */ @@ -309,6 +315,10 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent map((sectionState) => sectionState.data as WorkspaceitemSectionCcLicenseObject), ).subscribe((data) => { const wasUri = this.data.uri; + if (!hasValue(data.accepted)) { + // Section data from the REST API, so any queued patch has been saved + this.uriRemoveQueued = false; + } // The REST API never returns the accepted state, so only changes made in this section (which always // set it) should queue a patch. Otherwise a save response would queue a remove of an already removed uri. @@ -320,13 +330,16 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent if (licenseLink$) { licenseLink$.pipe( take(1), - filter(link => !!link && link !== wasUri) // Only proceed if link exists and is different + // Only proceed if link exists and is different, or the stored one is about to be removed + filter(link => !!link && (this.uriRemoveQueued || link !== wasUri)) ).subscribe((link) => { this.operationsBuilder.add(path, link.toString(), false, true); + this.uriRemoveQueued = false; }); } } else if (this.data.uri) { this.operationsBuilder.remove(path); + this.uriRemoveQueued = true; } } const previousCcLicense = this.data?.ccLicense; From b09b3f2f9bc4ebd33c75061ec55593c019e0a27e Mon Sep 17 00:00:00 2001 From: Mario Lukac Date: Wed, 4 Nov 2026 15:25:32 +0100 Subject: [PATCH 9/9] VSB-TUO: queue a re-accepted stored CC licence right away When a saved licence is unticked and ticked again with the same selection, add the stored uri synchronously instead of after the asynchronous link lookup, so a save dispatched in between cannot send only the remove. Co-Authored-By: Claude Opus 5.5 --- ...sion-section-cc-licenses.component.spec.ts | 27 +++++++++ ...ubmission-section-cc-licenses.component.ts | 60 +++++++++++++++---- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index 3c12c0e380c..268f08aaf4e 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -393,6 +393,33 @@ describe('SubmissionSectionCcLicensesComponent', () => { expect(operationsBuilder.add).toHaveBeenCalledTimes(1); }); + describe('when the stored license is selected', () => { + const cc0Uri = 'https://creativecommons.org/publicdomain/zero/1.0/'; + const cc0License: SubmissionCcLicence = { + id: 'cc0', type: SUBMISSION_CC_LICENSE, name: 'CC0 1.0 Universal', fields: [], _links: submissionCcLicenses[0]._links, + }; + + beforeEach(() => { + component.submissionCcLicenses = [cc0License]; + submissionCcLicenseUrlDataService.getCcLicenseLink.calls.reset(); + }); + + it('should queue the stored uri right away when it is ticked again before saving', () => { + component.sectionData.data = { uri: cc0Uri, ccLicense: { id: 'cc0', fields: {} } }; + sectionState$.next({ data: { uri: cc0Uri, ccLicense: { id: 'cc0', fields: {} }, accepted: false } }); + sectionState$.next({ data: { uri: cc0Uri, ccLicense: { id: 'cc0', fields: {} }, accepted: true } }); + expect(operationsBuilder.add).toHaveBeenCalledOnceWith(jasmine.anything(), cc0Uri, false, true); + expect(submissionCcLicenseUrlDataService.getCcLicenseLink).not.toHaveBeenCalled(); + }); + + it('should look up the link when the selection differs from the stored uri', () => { + component.sectionData.data = { uri, ccLicense: { id: 'cc0', fields: {} } }; + sectionState$.next({ data: { uri, ccLicense: { id: 'cc0', fields: {} }, accepted: false } }); + sectionState$.next({ data: { uri, ccLicense: { id: 'cc0', fields: {} }, accepted: true } }); + expect(operationsBuilder.add).toHaveBeenCalledOnceWith(jasmine.anything(), 'test cc license link', false, true); + }); + }); + it('should not queue a duplicate add for a license the server already stores', () => { component.sectionData.data = { uri }; sectionState$.next({ data: { uri, accepted: false } }); diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index 816414f2be4..7fecf2f16f0 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -324,7 +324,12 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent // set it) should queue a patch. Otherwise a save response would queue a remove of an already removed uri. if (hasValue(data.accepted) && this.data.accepted !== data.accepted) { const path = this.pathCombiner.getPath('uri'); - if (data.accepted) { + if (data.accepted && this.uriRemoveQueued && wasUri && this.isSelectionForUri(wasUri)) { + // The stored license is accepted again before its remove was saved. Queue it right away, as an add + // from the asynchronous link lookup below could miss a save that is already on its way. + this.operationsBuilder.add(path, wasUri, false, true); + this.uriRemoveQueued = false; + } else if (data.accepted) { // Only add URI if we're switching from not accepted to accepted const licenseLink$ = this.getCcLicenseLink$(); if (licenseLink$) { @@ -424,14 +429,50 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent if (hasValue(this.data?.ccLicense) || !this.storedCcLicenseLink) { return; } - const parsed = this.parseCcLicenseUri(this.storedCcLicenseLink); - if (!parsed) { + const selection = this.getSelectionForUri(this.storedCcLicenseLink); + if (!selection) { + // Unknown uri, or the license might be on a page that hasn't been loaded yet return; } + this.selectedCcLicense = selection.license; + this.updateSectionData({ + ccLicense: { + id: selection.license.id, + fields: selection.fields, + }, + }); + } + + /** + * Whether the currently selected license and options are the ones the given license uri was issued for. + * @param uri the license uri + */ + private isSelectionForUri(uri: string): boolean { + const selection = this.getSelectionForUri(uri); + const selectedCcLicense = this.getSelectedCcLicense(); + if (!selection || !selectedCcLicense || selectedCcLicense.id !== selection.license.id) { + return false; + } + return (selection.license.fields ?? []).every((field) => { + const expected = selection.fields[field.id]; + const actual = this.getSelectedOption(selectedCcLicense, field); + return !!expected && !!actual && expected.id === actual.id; + }); + } + + /** + * Get the license and field answers that a given license uri was issued for. + * Returns null when the uri is not a known Creative Commons license or the license isn't loaded. + * @param uri the license uri + */ + private getSelectionForUri(uri: string): { license: SubmissionCcLicence, fields: { [fieldId: string]: Option } } { + const parsed = this.parseCcLicenseUri(uri); + if (!parsed) { + return null; + } const license = this.submissionCcLicenses.find((ccLicense) => parsed.matches(ccLicense.name ?? '')); if (!license) { - // The license might be on a page that hasn't been loaded yet - return; + return null; } const fields: { [fieldId: string]: Option } = {}; @@ -457,14 +498,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent fields[field.id] = option; } }); - - this.selectedCcLicense = license; - this.updateSectionData({ - ccLicense: { - id: license.id, - fields, - }, - }); + return { license, fields }; } /**