From 837a598e48e46a368edc56a593fcce70c9cb3582 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 11 Oct 2025 21:31:53 +0100 Subject: [PATCH 01/10] Move roles along with person - When person moves, moves roles with the person - Add remove button to remove a person at any position in list Signed-off-by: Arthit Suriyawongkul --- cypress/integration/persons.js | 96 ++++++++++++++++++ js/dynamic_form.js | 177 +++++++++++++++++++++++---------- main.css | 1 + 3 files changed, 220 insertions(+), 54 deletions(-) diff --git a/cypress/integration/persons.js b/cypress/integration/persons.js index f86426e..9b9d652 100644 --- a/cypress/integration/persons.js +++ b/cypress/integration/persons.js @@ -395,6 +395,30 @@ describe('Author order change', function() { }); }); + it('moves roles with person', function() { + cy.get('#name').type('My Test Software'); + + cy.get('#author_add').click(); + cy.get('#author_add').click(); + cy.get('#author_1_givenName').type('Jane'); + + cy.get('#author_1_role_add').click(); + cy.get('#author_1_roleName_0').type('Developer'); + + cy.get('#author_2_givenName').type('John'); + + // Move author 1 to the right (swap with author 2) + cy.get('#author_1_moveToRight').click(); + + // After the swap, Jane (and her role) should be at author_2 + cy.get('#author_2_givenName').should('have.value', 'Jane'); + cy.get('#author_2_roleName_0').should('have.value', 'Developer'); + + // John should now be at author_1 and should not have the role + cy.get('#author_1_givenName').should('have.value', 'John'); + cy.get('#author_1_roleName_0').should('not.exist'); + }); + it('wraps around to the right', function() { cy.get('#name').type('My Test Software'); @@ -889,6 +913,78 @@ describe('Multiple authors', function () { cy.get('#author_1_endDate_0').should('have.value', '2024-04-03'); cy.get('#author_2_givenName').should('have.value', 'Joe'); }); + + it('can remove the first one and reindexes remaining ones', function() { + cy.get('#name').type('My Test Software'); + + cy.get('#author_add').click(); + cy.get('#author_add').click(); + cy.get('#author_nb').should('have.value', '2'); + + cy.get('#author_1_givenName').type('Alice'); + cy.get('#author_2_givenName').type('Bob'); + + cy.get('#author_1_remove').click(); + + cy.get('#author_nb').should('have.value', '1'); + cy.get('#author_1_givenName').should('have.value', 'Bob'); + + cy.get('#generateCodemetaV2').click(); + cy.get('#codemetaText').then((elem) => JSON.parse(elem.text())) + .should('deep.equal', { + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "type": "SoftwareSourceCode", + "name": "My Test Software", + "author": [ + { + "id": "_:author_1", + "type": "Person", + "givenName": "Bob" + } + ], + }); + }); + + it('can remove a middle one and reindexes remaining ones', function() { + cy.get('#name').type('My Test Software'); + + cy.get('#author_add').click(); + cy.get('#author_add').click(); + cy.get('#author_add').click(); + cy.get('#author_nb').should('have.value', '3'); + + cy.get('#author_1_givenName').type('Alice'); + cy.get('#author_2_givenName').type('Bob'); + cy.get('#author_3_givenName').type('Carol'); + + cy.get('#author_2_remove').click(); + + cy.get('#author_nb').should('have.value', '2'); + cy.get('#author_1_givenName').should('have.value', 'Alice'); + cy.get('#author_2_givenName').should('have.value', 'Carol'); + + cy.get('#generateCodemetaV2').click(); + cy.get('#codemetaText').then((elem) => JSON.parse(elem.text())) + .should('deep.equal', { + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "type": "SoftwareSourceCode", + "name": "My Test Software", + "author": [ + { + "id": "_:author_1", + "type": "Person", + "givenName": "Alice" + }, + { + "id": "_:author_2", + "type": "Person", + "givenName": "Carol" + } + ], + }); + }); + + }); describe('Contributors', function () { diff --git a/js/dynamic_form.js b/js/dynamic_form.js index 8e79b4f..5c0e942 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -18,8 +18,7 @@ const personFields = [ function createPersonFieldset(personPrefix, legend) { // Creates a fieldset containing inputs for informations about a person - var fieldset = document.createElement("fieldset") - var moveButtons; + const fieldset = document.createElement("fieldset") fieldset.classList.add("person"); fieldset.classList.add("leafFieldset"); fieldset.id = personPrefix; @@ -29,6 +28,8 @@ function createPersonFieldset(personPrefix, legend) {
+
@@ -65,85 +66,150 @@ function createPersonFieldset(personPrefix, legend) { } function addPersonWithId(container, prefix, legend, id) { - var personPrefix = `${prefix}_${id}`; - var fieldset = createPersonFieldset(personPrefix, `${legend} #${id}`); + const personPrefix = `${prefix}_${id}`; + const fieldset = createPersonFieldset(personPrefix, legend); container.appendChild(fieldset); - document.querySelector(`#${personPrefix}_moveToLeft`) - .addEventListener('click', () => movePerson(prefix, id, "left")); - document.querySelector(`#${personPrefix}_moveToRight`) - .addEventListener('click', () => movePerson(prefix, id, "right")); - document.querySelector(`#${personPrefix}_role_add`) - .addEventListener('click', () => addRole(personPrefix)); + // Use ID selector to attach handlers that compute the current fieldset + // ID at click time, so renaming the IDs (when renumbering persons) + // won't break the handlers. + fieldset.querySelector('[id$="_moveToLeft"]') + .addEventListener('click', () => movePerson(prefix, fieldset.id, 'left')); + fieldset.querySelector('[id$="_moveToRight"]') + .addEventListener('click', () => movePerson(prefix, fieldset.id, 'right')); + fieldset.querySelector('[id$="_remove"]') + .addEventListener('click', () => removePerson(prefix, fieldset.id)); + fieldset.querySelector('[id$="_role_add"]') + .addEventListener('click', () => addRole(fieldset.id)); } -function movePerson(prefix, id1, direction) { - var nbPersons = getNbPersons(prefix); - var id2; +function movePerson(prefix, personPrefix, direction) { + const container = document.querySelector(`#${prefix}_container`); + if (!container) return; - // Computer id2, the id of the person to flip id1 with (wraps around the - // end of the list of persons) - if (direction == "left") { - id2 = id1 - 1; - if (id2 <= 0) { - id2 = nbPersons; - } - } - else { - id2 = id1 + 1; - if (id2 > nbPersons) { - id2 = 1; - } - } + const persons = Array.from(container.querySelectorAll('.person')); + const len = persons.length; + const currentIndex = persons.findIndex(function (p) { return p.id === personPrefix; }); + if (currentIndex === -1 || len <= 1) return; + + const targetIndex = (direction === 'left') ? ((currentIndex - 1 + len) % len) : ((currentIndex + 1) % len); + if (targetIndex === currentIndex) return; + + const swapped = persons.slice(); + const tmp = swapped[targetIndex]; + swapped[targetIndex] = swapped[currentIndex]; + swapped[currentIndex] = tmp; - // Flip the field values, one by one - personFields.forEach((fieldName) => { - var field1 = document.querySelector(`#${prefix}_${id1}_${fieldName}`); - var field2 = document.querySelector(`#${prefix}_${id2}_${fieldName}`); - var value1 = field1.value; - var value2 = field2.value; - field2.value = value1; - field1.value = value2; - }); - - // Form was changed; regenerate + // Re-append nodes in new order + const frag = document.createDocumentFragment(); + swapped.forEach(function (p) { frag.appendChild(p); }); + container.appendChild(frag); + + renumberPersons(prefix); generateCodemeta(); } function addPerson(prefix, legend) { - var container = document.querySelector(`#${prefix}_container`); - var personId = getNbPersons(prefix) + 1; + const container = document.querySelector(`#${prefix}_container`); + const personId = getNbPersons(prefix) + 1; addPersonWithId(container, prefix, legend, personId); + renumberPersons(prefix); + return personId; +} - setNbPersons(prefix, personId); +function removePerson(prefix, personPrefix) { + const container = document.querySelector(`#${prefix}_container`); + if (!container) return; + + if (personPrefix) { + const fs = document.querySelector(`#${personPrefix}`); + if (!fs) return; + fs.remove(); + } else { + // If no personPrefix is provided, remove the last person + const persons = Array.from(container.querySelectorAll('.person')); + if (persons.length === 0) return; + const last = persons[persons.length - 1]; + last.remove(); + } - return personId; + renumberPersons(prefix); + generateCodemeta(); } -function removePerson(prefix) { - var personId = getNbPersons(prefix); +function renumberPersons(prefix) { + // Assume id pattern of "prefix_index_suffix" + function idSuffix(id) { + if (!id) return ''; + const parts = id.split('_'); + if (parts.length <= 1) return id; + return parts.slice(2).join('_'); + } + + const container = document.querySelector(`#${prefix}_container`); + if (!container) return; - document.querySelector(`#${prefix}_${personId}`).remove(); + const persons = Array.from(container.querySelectorAll('.person')); + for (let i = 0; i < persons.length; i++) { + const fs = persons[i]; + const n = i + 1; + const newPersonPrefix = `${prefix}_${n}`; + + fs.id = newPersonPrefix; + + const legend = fs.querySelector('legend'); + if (legend) { + let base = legend.textContent.split('#')[0].trim(); + if (base === '') base = legend.textContent; + legend.textContent = base + ' #' + n; + } - setNbPersons(prefix, personId - 1); + // Update descendant ids and names + Array.from(fs.querySelectorAll('[id]')).forEach(function (el) { + const oldId = el.id; + const suffix = idSuffix(oldId); + + el.id = `${newPersonPrefix}_${suffix}`; + + if (el.name) { + const nameSuffix = idSuffix(el.name); + el.name = `${newPersonPrefix}_${nameSuffix}`; + } + }); + + // Update label 'for' attributes + Array.from(fs.querySelectorAll('label[for]')).forEach(function (label) { + if (!label.htmlFor) return; + const forSuffix = idSuffix(label.htmlFor); + label.htmlFor = `${newPersonPrefix}_${forSuffix}`; + }); + } + + setNbPersons(prefix, persons.length); } // Initialize a group of persons (authors, contributors) on page load. // Useful if the page is reloaded. function initPersons(prefix, legend) { - var nbPersons = getNbPersons(prefix); - var personContainer = document.querySelector(`#${prefix}_container`) + const container = document.querySelector(`#${prefix}_container`); + if (!container) return; - for (let personId = 1; personId <= nbPersons; personId++) { - addPersonWithId(personContainer, prefix, legend, personId); + const existing = Array.from(container.querySelectorAll('.person')); + if (existing.length > 0) { + renumberPersons(prefix); + return; + } + + const nbPersons = getNbPersons(prefix); + for (let i = 0; i < nbPersons; i++) { + addPerson(prefix, legend); } } function removePersons(prefix) { - var nbPersons = getNbPersons(prefix); - var personContainer = document.querySelector(`#${prefix}_container`) + const nbPersons = getNbPersons(prefix); for (let personId = 1; personId <= nbPersons; personId++) { removePerson(prefix) @@ -171,8 +237,11 @@ function addRole(personPrefix) { `; roleButtonGroup.after(ul); - document.querySelector(`#${personPrefix}_role_remove_${roleIndex}`) - .addEventListener('click', () => removeRole(personPrefix, roleIndex)); + ul.querySelector(`[id$="_role_remove_${roleIndex}"]`) + .addEventListener('click', (e) => { + const pid = e.currentTarget.closest?.('.person')?.id; + removeRole(pid, roleIndex); + }); roleIndexNode.value = roleIndex + 1; diff --git a/main.css b/main.css index e4cfaab..cf6d961 100644 --- a/main.css +++ b/main.css @@ -51,6 +51,7 @@ p input, p textarea { width: 100%; display: flex; justify-content: space-between; + align-items: center; } #license { From 92acee97eb109e85d3ddd0c16ab55153a555c226 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 12 Oct 2025 10:45:19 +0100 Subject: [PATCH 02/10] Directly swap DOM in movePerson Also trying to do getElementById when possible, instead of querySelector(#id) Signed-off-by: Arthit Suriyawongkul --- cypress/integration/persons.js | 9 +-- index.html | 4 ++ js/dynamic_form.js | 109 ++++++++++++++++++++------------- 3 files changed, 75 insertions(+), 47 deletions(-) diff --git a/cypress/integration/persons.js b/cypress/integration/persons.js index 9b9d652..0554400 100644 --- a/cypress/integration/persons.js +++ b/cypress/integration/persons.js @@ -398,15 +398,16 @@ describe('Author order change', function() { it('moves roles with person', function() { cy.get('#name').type('My Test Software'); - cy.get('#author_add').click(); cy.get('#author_add').click(); cy.get('#author_1_givenName').type('Jane'); - cy.get('#author_1_role_add').click(); - cy.get('#author_1_roleName_0').type('Developer'); - + cy.get('#author_add').click(); cy.get('#author_2_givenName').type('John'); + cy.get('#author_1_role_add').click(); + cy.get('#author_1_roleName_0').type('Developer'); + cy.get('#author_1_roleName_0').should('have.value', 'Developer'); + // Move author 1 to the right (swap with author 2) cy.get('#author_1_moveToRight').click(); diff --git a/index.html b/index.html index acaa027..eaf98fd 100644 --- a/index.html +++ b/index.html @@ -372,6 +372,8 @@

CodeMeta Generator v3.0

+ +
@@ -386,6 +388,8 @@

CodeMeta Generator v3.0

+ +
diff --git a/js/dynamic_form.js b/js/dynamic_form.js index 5c0e942..608ac55 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -85,7 +85,7 @@ function addPersonWithId(container, prefix, legend, id) { } function movePerson(prefix, personPrefix, direction) { - const container = document.querySelector(`#${prefix}_container`); + const container = document.getElementById(`${prefix}_list`); if (!container) return; const persons = Array.from(container.querySelectorAll('.person')); @@ -93,25 +93,46 @@ function movePerson(prefix, personPrefix, direction) { const currentIndex = persons.findIndex(function (p) { return p.id === personPrefix; }); if (currentIndex === -1 || len <= 1) return; - const targetIndex = (direction === 'left') ? ((currentIndex - 1 + len) % len) : ((currentIndex + 1) % len); - if (targetIndex === currentIndex) return; + const currentElement = document.getElementById(personPrefix); - const swapped = persons.slice(); - const tmp = swapped[targetIndex]; - swapped[targetIndex] = swapped[currentIndex]; - swapped[currentIndex] = tmp; + function swapAdjacent(a, b) { + const parent = a && a.parentNode; + if (!parent) return; + if (a.nextElementSibling === b) { + parent.insertBefore(b, a); + } else if (b.nextElementSibling === a) { + parent.insertBefore(a, b); + } + } - // Re-append nodes in new order - const frag = document.createDocumentFragment(); - swapped.forEach(function (p) { frag.appendChild(p); }); - container.appendChild(frag); + const prev = currentElement.previousElementSibling; + const next = currentElement.nextElementSibling; + + if (direction === 'left') { + if (!prev) { + // Current is first element -> move to end (wrap-around) + container.appendChild(currentElement); + } else { + // Swap with previous element + swapAdjacent(prev, currentElement); + } + } else { + if (!next) { + // Current is last element -> move to beginning (wrap-around) + const firstEl = container.firstElementChild; + if (firstEl) container.insertBefore(currentElement, firstEl); + } else { + // Swap with next element + swapAdjacent(currentElement, next); + } + } renumberPersons(prefix); generateCodemeta(); } function addPerson(prefix, legend) { - const container = document.querySelector(`#${prefix}_container`); + const container = document.getElementById(`${prefix}_list`); const personId = getNbPersons(prefix) + 1; addPersonWithId(container, prefix, legend, personId); @@ -120,18 +141,17 @@ function addPerson(prefix, legend) { } function removePerson(prefix, personPrefix) { - const container = document.querySelector(`#${prefix}_container`); + const container = document.getElementById(`${prefix}_list`); if (!container) return; if (personPrefix) { - const fs = document.querySelector(`#${personPrefix}`); + const fs = document.getElementById(personPrefix); if (!fs) return; fs.remove(); } else { // If no personPrefix is provided, remove the last person - const persons = Array.from(container.querySelectorAll('.person')); - if (persons.length === 0) return; - const last = persons[persons.length - 1]; + const last = container.lastElementChild; + if (!last) return; last.remove(); } @@ -144,11 +164,11 @@ function renumberPersons(prefix) { function idSuffix(id) { if (!id) return ''; const parts = id.split('_'); - if (parts.length <= 1) return id; + if (parts.length <= 2) return ''; return parts.slice(2).join('_'); } - const container = document.querySelector(`#${prefix}_container`); + const container = document.getElementById(`${prefix}_list`); if (!container) return; const persons = Array.from(container.querySelectorAll('.person')); @@ -193,7 +213,7 @@ function renumberPersons(prefix) { // Initialize a group of persons (authors, contributors) on page load. // Useful if the page is reloaded. function initPersons(prefix, legend) { - const container = document.querySelector(`#${prefix}_container`); + const container = document.getElementById(`${prefix}_list`); if (!container) return; const existing = Array.from(container.querySelectorAll('.person')); @@ -209,16 +229,19 @@ function initPersons(prefix, legend) { } function removePersons(prefix) { - const nbPersons = getNbPersons(prefix); + const container = document.getElementById(`${prefix}_list`); + if (!container) return; - for (let personId = 1; personId <= nbPersons; personId++) { - removePerson(prefix) - } + const persons = Array.from(container.querySelectorAll('.person')); + persons.forEach(p => p.remove()); + + renumberPersons(prefix); + generateCodemeta(); } function addRole(personPrefix) { - const roleButtonGroup = document.querySelector(`#${personPrefix}_role_add`); - const roleIndexNode = document.querySelector(`#${personPrefix}_role_index`); + const roleButtonGroup = document.getElementById(`${personPrefix}_role_add`); + const roleIndexNode = document.getElementById(`${personPrefix}_role_index`); const roleIndex = parseInt(roleIndexNode.value, 10); const ul = document.createElement("ul") @@ -237,7 +260,7 @@ function addRole(personPrefix) { `; roleButtonGroup.after(ul); - ul.querySelector(`[id$="_role_remove_${roleIndex}"]`) + document.getElementById(`${personPrefix}_role_remove_${roleIndex}`) .addEventListener('click', (e) => { const pid = e.currentTarget.closest?.('.person')?.id; removeRole(pid, roleIndex); @@ -249,7 +272,7 @@ function addRole(personPrefix) { } function removeRole(personPrefix, roleIndex) { - document.querySelector(`#${personPrefix}_role_${roleIndex}`).remove(); + document.getElementById(`${personPrefix}_role_${roleIndex}`).remove(); } function resetForm() { @@ -260,7 +283,7 @@ function resetForm() { // Reset the form after deleting elements, so nbPersons doesn't get // reset before it's read. - document.querySelector('#inputForm').reset(); + document.getElementById('inputForm').reset(); } function fieldToLower(event) { @@ -276,41 +299,41 @@ function initCallbacks() { // In Firefox datalist selection without Enter press does not trigger // 'change' event, so we need to listen to 'input' event to catch // a selection with mouse click. - document.querySelector('#license') + document.getElementById('license') .addEventListener('input', validateLicense); - document.querySelector('#license') + document.getElementById('license') .addEventListener('change', validateLicense); // Safari needs 'keydown' to catch Enter press when datalist is shown - document.querySelector('#license') + document.getElementById('license') .addEventListener('keydown', validateLicense); - document.querySelector('#generateCodemetaV2').disabled = false; - document.querySelector('#generateCodemetaV2') + document.getElementById('generateCodemetaV2').disabled = false; + document.getElementById('generateCodemetaV2') .addEventListener('click', () => generateCodemeta("2.0")); - document.querySelector('#generateCodemetaV3').disabled = false; - document.querySelector('#generateCodemetaV3') + document.getElementById('generateCodemetaV3').disabled = false; + document.getElementById('generateCodemetaV3') .addEventListener('click', () => generateCodemeta("3.0")); - document.querySelector('#resetForm') + document.getElementById('resetForm') .addEventListener('click', resetForm); - document.querySelector('#validateCodemeta').disabled = false; - document.querySelector('#validateCodemeta') + document.getElementById('validateCodemeta').disabled = false; + document.getElementById('validateCodemeta') .addEventListener('click', () => parseAndValidateCodemeta(true)); - document.querySelector('#importCodemeta').disabled = false; - document.querySelector('#importCodemeta') + document.getElementById('importCodemeta').disabled = false; + document.getElementById('importCodemeta') .addEventListener('click', importCodemeta); document.querySelector('#downloadCodemeta input').disabled = false; document.querySelector('#downloadCodemeta input') .addEventListener('click', downloadCodemeta); - document.querySelector('#inputForm') + document.getElementById('inputForm') .addEventListener('change', () => generateCodemeta()); - document.querySelector('#developmentStatus') + document.getElementById('developmentStatus') .addEventListener('change', fieldToLower); initPersons('author', 'Author'); From aa1aeb0190946aa348e89f7bcf8843f1d03dfd62 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 12 Oct 2025 15:54:20 +0100 Subject: [PATCH 03/10] Keep error message after generateCodemeta() call Signed-off-by: Arthit Suriyawongkul --- js/codemeta_generation.js | 5 +++++ js/dynamic_form.js | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index b4e3c81..983c683 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -429,12 +429,17 @@ async function recompactDocWithAllContexts(doc) { async function importCodemeta() { var inputForm = document.querySelector('#inputForm'); var doc = parseAndValidateCodemeta(false); + // parseAndValidateCodemeta() may set an error message. + // resetForm() below runs generateCodemeta() which would clear the error message; + // preserve the error message so it is there after import. + const validationError = (document.getElementById('errorMessage') || {}).textContent || ''; // Re-compact document with all contexts // to allow importing property from any context doc = await recompactDocWithAllContexts(doc); resetForm(); + if (validationError) setError(validationError); if (doc['license'] !== undefined) { if (typeof doc['license'] === 'string') { diff --git a/js/dynamic_form.js b/js/dynamic_form.js index 608ac55..81d4400 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -232,10 +232,8 @@ function removePersons(prefix) { const container = document.getElementById(`${prefix}_list`); if (!container) return; - const persons = Array.from(container.querySelectorAll('.person')); - persons.forEach(p => p.remove()); - - renumberPersons(prefix); + container.innerHTML = ''; + setNbPersons(prefix, 0); generateCodemeta(); } @@ -273,6 +271,7 @@ function addRole(personPrefix) { function removeRole(personPrefix, roleIndex) { document.getElementById(`${personPrefix}_role_${roleIndex}`).remove(); + generateCodemeta(); } function resetForm() { From 81e69121007cc0249a004202fa4125d6a307257a Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sun, 12 Oct 2025 16:59:18 +0100 Subject: [PATCH 04/10] Fix wrap around test cases Use names One, Two, Three .. to make it easy to follow Signed-off-by: Arthit Suriyawongkul --- cypress/integration/persons.js | 56 +++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/cypress/integration/persons.js b/cypress/integration/persons.js index 0554400..3943389 100644 --- a/cypress/integration/persons.js +++ b/cypress/integration/persons.js @@ -426,25 +426,31 @@ describe('Author order change', function() { cy.get('#author_add').click(); cy.get('#author_add').click(); cy.get('#author_add').click(); - cy.get('#author_1_givenName').type('Jane'); + cy.get('#author_add').click(); + cy.get('#author_1_givenName').type('One'); cy.get('#author_1_affiliation').type('Example Org'); - cy.get('#author_2_givenName').type('John'); - cy.get('#author_2_familyName').type('Doe'); - cy.get('#author_3_givenName').type('Alex'); + cy.get('#author_2_givenName').type('Two'); + cy.get('#author_2_familyName').type('Too'); + cy.get('#author_3_givenName').type('Three'); + cy.get('#author_4_givenName').type('Four'); cy.get('#author_1_moveToLeft').click() - cy.get('#author_1_givenName').should('have.value', 'Alex'); - cy.get('#author_1_familyName').should('have.value', ''); + cy.get('#author_1_givenName').should('have.value', 'Two'); + cy.get('#author_1_familyName').should('have.value', 'Too'); cy.get('#author_1_affiliation').should('have.value', ''); - cy.get('#author_2_givenName').should('have.value', 'John'); - cy.get('#author_2_familyName').should('have.value', 'Doe'); + cy.get('#author_2_givenName').should('have.value', 'Three'); + cy.get('#author_2_familyName').should('have.value', ''); cy.get('#author_2_affiliation').should('have.value', ''); - cy.get('#author_3_givenName').should('have.value', 'Jane'); + cy.get('#author_3_givenName').should('have.value', 'Four'); cy.get('#author_3_familyName').should('have.value', ''); - cy.get('#author_3_affiliation').should('have.value', 'Example Org'); + cy.get('#author_3_affiliation').should('have.value', ''); + + cy.get('#author_4_givenName').should('have.value', 'One'); + cy.get('#author_4_familyName').should('have.value', ''); + cy.get('#author_4_affiliation').should('have.value', 'Example Org'); }); it('wraps around to the left', function() { @@ -453,25 +459,31 @@ describe('Author order change', function() { cy.get('#author_add').click(); cy.get('#author_add').click(); cy.get('#author_add').click(); - cy.get('#author_1_givenName').type('Jane'); + cy.get('#author_add').click(); + cy.get('#author_1_givenName').type('One'); cy.get('#author_1_affiliation').type('Example Org'); - cy.get('#author_2_givenName').type('John'); - cy.get('#author_2_familyName').type('Doe'); - cy.get('#author_3_givenName').type('Alex'); + cy.get('#author_2_givenName').type('Two'); + cy.get('#author_2_familyName').type('Too'); + cy.get('#author_3_givenName').type('Three'); + cy.get('#author_4_givenName').type('Four'); - cy.get('#author_3_moveToRight').click() + cy.get('#author_4_moveToRight').click() - cy.get('#author_1_givenName').should('have.value', 'Alex'); + cy.get('#author_1_givenName').should('have.value', 'Four'); cy.get('#author_1_familyName').should('have.value', ''); cy.get('#author_1_affiliation').should('have.value', ''); - cy.get('#author_2_givenName').should('have.value', 'John'); - cy.get('#author_2_familyName').should('have.value', 'Doe'); - cy.get('#author_2_affiliation').should('have.value', ''); + cy.get('#author_2_givenName').should('have.value', 'One'); + cy.get('#author_2_familyName').should('have.value', ''); + cy.get('#author_2_affiliation').should('have.value', 'Example Org'); - cy.get('#author_3_givenName').should('have.value', 'Jane'); - cy.get('#author_3_familyName').should('have.value', ''); - cy.get('#author_3_affiliation').should('have.value', 'Example Org'); + cy.get('#author_3_givenName').should('have.value', 'Two'); + cy.get('#author_3_familyName').should('have.value', 'Too'); + cy.get('#author_3_affiliation').should('have.value', ''); + + cy.get('#author_4_givenName').should('have.value', 'Three'); + cy.get('#author_4_familyName').should('have.value', ''); + cy.get('#author_4_affiliation').should('have.value', ''); }); }); From 6bdf21bbec5a617af70346891974bc4cfccd1842 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 13 Oct 2025 00:27:04 +0100 Subject: [PATCH 05/10] Add comments Signed-off-by: Arthit Suriyawongkul --- js/dynamic_form.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/dynamic_form.js b/js/dynamic_form.js index 81d4400..6d507ca 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -216,12 +216,15 @@ function initPersons(prefix, legend) { const container = document.getElementById(`${prefix}_list`); if (!container) return; + // If there are already persons, do not add new ones, + // renumber them if needed. const existing = Array.from(container.querySelectorAll('.person')); if (existing.length > 0) { renumberPersons(prefix); return; } + // If no persons, add empty ones const nbPersons = getNbPersons(prefix); for (let i = 0; i < nbPersons; i++) { addPerson(prefix, legend); From 0a33d2d6f3ec6508b14b912ad9a0690cf5229c5b Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 18 Oct 2025 19:31:17 +0100 Subject: [PATCH 06/10] reset for first; remove unused variables Signed-off-by: Arthit Suriyawongkul --- js/codemeta_generation.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 983c683..86cfdd4 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -427,20 +427,13 @@ async function recompactDocWithAllContexts(doc) { } async function importCodemeta() { - var inputForm = document.querySelector('#inputForm'); - var doc = parseAndValidateCodemeta(false); - // parseAndValidateCodemeta() may set an error message. - // resetForm() below runs generateCodemeta() which would clear the error message; - // preserve the error message so it is there after import. - const validationError = (document.getElementById('errorMessage') || {}).textContent || ''; + resetForm(); + var doc = parseAndValidateCodemeta(false); // Re-compact document with all contexts // to allow importing property from any context doc = await recompactDocWithAllContexts(doc); - resetForm(); - if (validationError) setError(validationError); - if (doc['license'] !== undefined) { if (typeof doc['license'] === 'string') { doc['license'] = [doc['license']]; @@ -463,7 +456,6 @@ async function importCodemeta() { splittedCodemetaFields.forEach(function (item, index) { const id = item[0]; const separator = item[1]; - const serializer = item[2]; const deserializer = item[3]; let value = doc[id]; if (value !== undefined) { From 8a888a2b1af7f5b5c7a05adbd385ade9c495eda2 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 18 Oct 2025 19:37:33 +0100 Subject: [PATCH 07/10] Fix typo Signed-off-by: Arthit Suriyawongkul --- cypress/integration/persons.js | 2 +- js/fields_data.js | 4 ++-- js/validation/index.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/integration/persons.js b/cypress/integration/persons.js index 3943389..03eadb1 100644 --- a/cypress/integration/persons.js +++ b/cypress/integration/persons.js @@ -407,7 +407,7 @@ describe('Author order change', function() { cy.get('#author_1_role_add').click(); cy.get('#author_1_roleName_0').type('Developer'); cy.get('#author_1_roleName_0').should('have.value', 'Developer'); - + // Move author 1 to the right (swap with author 2) cy.get('#author_1_moveToRight').click(); diff --git a/js/fields_data.js b/js/fields_data.js index 0d67894..8828fdb 100644 --- a/js/fields_data.js +++ b/js/fields_data.js @@ -65,7 +65,7 @@ function validateLicense(e) { 'insertReplacementText', // from datalist selection ]); if (!(e.inputType && CONFIRM_INPUT_TYPES.has(e.inputType))) { - // Typing characters, pasting, deletions - don't proceed + // Typing characters, pasting, deletions - don't proceed return; } } else { @@ -75,7 +75,7 @@ function validateLicense(e) { // Correct casing to the canonical SPDX license ID when possible. // This will allow user to type in any casing and hit Enter once - // to insert the license immediately. + // to insert the license immediately. const match = SPDX_LICENSE_IDS.find(id => id.toLowerCase() === license.toLowerCase()); if (match) { diff --git a/js/validation/index.js b/js/validation/index.js index 815a3b7..9bd1b48 100644 --- a/js/validation/index.js +++ b/js/validation/index.js @@ -8,7 +8,7 @@ /* * Reads a CodeMeta file and shows human-friendly errors on it. * - * This validator intentionaly does not use a schema, in order to show errors + * This validator intentionally does not use a schema, in order to show errors * that are easy to understand for users with no understanding of JSON-LD. */ From 67fc49e845598ccbda6ec93835dc8b5d441faa31 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 18 Oct 2025 19:46:04 +0100 Subject: [PATCH 08/10] Put resetForm() back to its original place Signed-off-by: Arthit Suriyawongkul --- js/codemeta_generation.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 86cfdd4..95b0f42 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -427,13 +427,13 @@ async function recompactDocWithAllContexts(doc) { } async function importCodemeta() { - resetForm(); - var doc = parseAndValidateCodemeta(false); // Re-compact document with all contexts // to allow importing property from any context doc = await recompactDocWithAllContexts(doc); + resetForm(); + if (doc['license'] !== undefined) { if (typeof doc['license'] === 'string') { doc['license'] = [doc['license']]; From 60acae4152743c65b27be3a758e6b3632cc438e0 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 18 Oct 2025 19:54:05 +0100 Subject: [PATCH 09/10] Preserve error message to satisfy test Signed-off-by: Arthit Suriyawongkul --- js/codemeta_generation.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 95b0f42..595cdd0 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -428,12 +428,21 @@ async function recompactDocWithAllContexts(doc) { async function importCodemeta() { var doc = parseAndValidateCodemeta(false); + + // parseAndValidateCodemeta() may set an error message. + // resetForm() below runs generateCodemeta() which would clear the error message; + // preserve the error message so it is there after import. + const validationError = (document.getElementById('errorMessage') || {}).textContent || ''; + // Re-compact document with all contexts // to allow importing property from any context doc = await recompactDocWithAllContexts(doc); resetForm(); + // Restore error message if any + if (validationError) setError(validationError); + if (doc['license'] !== undefined) { if (typeof doc['license'] === 'string') { doc['license'] = [doc['license']]; From bc1fddfb9a49b73bf2257b45dbe973522b5c03e0 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Tue, 21 Oct 2025 22:07:59 +0100 Subject: [PATCH 10/10] Use global flag to control codemeta generation Use codemetaGenerationEnabled to control if codemeta is to be generated or not Signed-off-by: Arthit Suriyawongkul Co-Authored-By: Val Lorentz --- cypress/integration/persons.js | 2 +- js/codemeta_generation.js | 123 +++++++++++++++++---------------- js/validation/index.js | 2 +- main.css | 2 +- 4 files changed, 68 insertions(+), 61 deletions(-) diff --git a/cypress/integration/persons.js b/cypress/integration/persons.js index 03eadb1..20f7799 100644 --- a/cypress/integration/persons.js +++ b/cypress/integration/persons.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2020 The Software Heritage developers + * Copyright (C) 2020-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 595cdd0..5f2e4b9 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2019-2020 The Software Heritage developers + * Copyright (C) 2019-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information @@ -7,6 +7,8 @@ "use strict"; +let codemetaGenerationEnabled = true; + const CODEMETA_CONTEXTS = { "2.0": { path: "./data/contexts/codemeta-2.0.jsonld", @@ -291,6 +293,12 @@ async function buildExpandedDocWithAllContexts() { // v2.0 is still default version for generation, for now async function generateCodemeta(codemetaVersion = "2.0") { + if (!codemetaGenerationEnabled) { + // Avoid regenerating a document while we are importing it. + // This avoid resetting the input in case there is an error in it. + return false; + } + var inputForm = document.querySelector('#inputForm'); var codemetaText, errorHTML; @@ -427,74 +435,73 @@ async function recompactDocWithAllContexts(doc) { } async function importCodemeta() { - var doc = parseAndValidateCodemeta(false); - - // parseAndValidateCodemeta() may set an error message. - // resetForm() below runs generateCodemeta() which would clear the error message; - // preserve the error message so it is there after import. - const validationError = (document.getElementById('errorMessage') || {}).textContent || ''; + // Don't wipe the codemeta text (if any) in case of error + codemetaGenerationEnabled = false; - // Re-compact document with all contexts - // to allow importing property from any context - doc = await recompactDocWithAllContexts(doc); + try { + var doc = parseAndValidateCodemeta(false); + // Re-compact document with all contexts + // to allow importing property from any context + doc = await recompactDocWithAllContexts(doc); - resetForm(); + resetForm(); - // Restore error message if any - if (validationError) setError(validationError); + if (doc['license'] !== undefined) { + if (typeof doc['license'] === 'string') { + doc['license'] = [doc['license']]; + } - if (doc['license'] !== undefined) { - if (typeof doc['license'] === 'string') { - doc['license'] = [doc['license']]; + doc['license'].forEach(l => { + if (l.indexOf(SPDX_PREFIX) !== 0) { return; } + let licenseId = l.substring(SPDX_PREFIX.length); + insertLicenseElement(licenseId); + }); } - doc['license'].forEach(l => { - if (l.indexOf(SPDX_PREFIX) !== 0) { return; } - let licenseId = l.substring(SPDX_PREFIX.length); - insertLicenseElement(licenseId); + directCodemetaFields.forEach(function (item, index) { + setIfDefined('#' + item, doc[item]); }); - } - - directCodemetaFields.forEach(function (item, index) { - setIfDefined('#' + item, doc[item]); - }); - importShortOrg('#funder', doc["funder"]); - importReview(doc["review"]); - - // Import simple fields by joining on their separator - splittedCodemetaFields.forEach(function (item, index) { - const id = item[0]; - const separator = item[1]; - const deserializer = item[3]; - let value = doc[id]; - if (value !== undefined) { - if (Array.isArray(value)) { - if (deserializer !== undefined) { - value = value.map((item) => deserializer(id, item)); - } - value = value.join(separator); - } else { - if (deserializer !== undefined) { - value = deserializer(id, value); + importShortOrg('#funder', doc["funder"]); + importReview(doc["review"]); + + // Import simple fields by joining on their separator + splittedCodemetaFields.forEach(function (item, index) { + const id = item[0]; + const separator = item[1]; + const deserializer = item[3]; + let value = doc[id]; + if (value !== undefined) { + if (Array.isArray(value)) { + if (deserializer !== undefined) { + value = value.map((item) => deserializer(id, item)); + } + value = value.join(separator); + } else { + if (deserializer !== undefined) { + value = deserializer(id, value); + } } + setIfDefined('#' + id, value); } - setIfDefined('#' + id, value); - } - }); - - for (const [key, items] of Object.entries(crossCodemetaFields)) { - let value = ""; - items.forEach(item => { - value = doc[item] || value; }); - setIfDefined(`#${key}`, value); - } - importPersons('author', 'Author', doc['author']); - if (doc['contributor']) { - // If only one contributor, it is compacted to an object - const contributors = Array.isArray(doc['contributor'])? doc['contributor'] : [doc['contributor']]; - importPersons('contributor', 'Contributor', contributors); + for (const [key, items] of Object.entries(crossCodemetaFields)) { + let value = ""; + items.forEach(item => { + value = doc[item] || value; + }); + setIfDefined(`#${key}`, value); + } + + importPersons('author', 'Author', doc['author']); + if (doc['contributor']) { + // If only one contributor, it is compacted to an object + const contributors = Array.isArray(doc['contributor']) ? doc['contributor'] : [doc['contributor']]; + importPersons('contributor', 'Contributor', contributors); + } + } finally { + // Re-enable codemeta generation + codemetaGenerationEnabled = true; } } diff --git a/js/validation/index.js b/js/validation/index.js index 9bd1b48..3519145 100644 --- a/js/validation/index.js +++ b/js/validation/index.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2020 The Software Heritage developers + * Copyright (C) 2020-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information diff --git a/main.css b/main.css index cf6d961..42abb3b 100644 --- a/main.css +++ b/main.css @@ -1,5 +1,5 @@ /** - * Copyright (C) 2019 The Software Heritage developers + * Copyright (C) 2019-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information