diff --git a/cypress/integration/basics.js b/cypress/integration/basics.js index 3777ee2..98ee876 100644 --- a/cypress/integration/basics.js +++ b/cypress/integration/basics.js @@ -1,110 +1,164 @@ /** * Copyright (C) 2020 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 */ /* * Tests the basic features of the application. */ "use strict"; describe('JSON Generation', function() { beforeEach(function() { /* Clear the session storage, as it is used to restore field data; * and we don't want a test to load data from the previous test. */ cy.window().then((win) => { win.sessionStorage.clear() }) cy.visit('./index.html'); }); it('works just from the software name', function() { cy.get('#name').type('My Test Software'); cy.get('#generateCodemeta').click(); cy.get('#errorMessage').should('have.text', ''); 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", }); }); - it('works just from all main fields', function() { + it('works just from all main fields when using only one license', function() { cy.get('#name').type('My Test Software'); cy.get('#description').type('This is a\ngreat piece of software'); cy.get('#dateCreated').type('2019-10-02'); cy.get('#datePublished').type('2020-01-01'); cy.get('#license').type('AGPL-3.0'); + cy.get("#license").type('{enter}'); cy.get('#generateCodemeta').click(); + cy.get("#license").should('have.value', ''); cy.get('#errorMessage').should('have.text', ''); cy.get('#codemetaText').then((elem) => JSON.parse(elem.text())) .should('deep.equal', { "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "license": "https://spdx.org/licenses/AGPL-3.0", "dateCreated": "2019-10-02", "datePublished": "2020-01-01", "name": "My Test Software", "description": "This is a\ngreat piece of software", }); }); + + it('works just from all main fields when using multiple licenses', function() { + cy.get('#name').type('My Test Software'); + cy.get('#description').type('This is a\ngreat piece of software'); + cy.get('#dateCreated').type('2019-10-02'); + cy.get('#datePublished').type('2020-01-01'); + cy.get('#license').type('AGPL-3.0'); + cy.get("#license").type('{enter}'); + cy.get('#license').type('MIT'); + cy.get("#license").type('{enter}'); + + cy.get('#generateCodemeta').click(); + + cy.get("#license").should('have.value', ''); + cy.get('#errorMessage').should('have.text', ''); + cy.get('#codemetaText').then((elem) => JSON.parse(elem.text())) + .should('deep.equal', { + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "@type": "SoftwareSourceCode", + "license": ["https://spdx.org/licenses/AGPL-3.0", "https://spdx.org/licenses/MIT"], + "dateCreated": "2019-10-02", + "datePublished": "2020-01-01", + "name": "My Test Software", + "description": "This is a\ngreat piece of software", + }); + }); }); describe('JSON Import', function() { it('works just from the software name', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "name": "My Test Software", })) ); cy.get('#importCodemeta').click(); cy.get('#name').should('have.value', 'My Test Software'); }); - it('works just from all main fields', function() { + it('works just from all main fields when using license as string', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "license": "https://spdx.org/licenses/AGPL-3.0", "dateCreated": "2019-10-02", "datePublished": "2020-01-01", "name": "My Test Software", "description": "This is a\ngreat piece of software", })) ); cy.get('#importCodemeta').click(); cy.get('#name').should('have.value', 'My Test Software'); cy.get('#description').should('have.value', 'This is a\ngreat piece of software'); cy.get('#dateCreated').should('have.value', '2019-10-02'); cy.get('#datePublished').should('have.value', '2020-01-01'); - cy.get('#license').should('have.value', 'AGPL-3.0'); + cy.get('#license').should('have.value', ''); + cy.get("#selected-licenses").children().should('have.length', 1); + cy.get("#selected-licenses").children().first().children().first().should('have.text', 'AGPL-3.0'); + }); + + it('works just from all main fields when using license as array', function() { + cy.get('#codemetaText').then((elem) => + elem.text(JSON.stringify({ + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "@type": "SoftwareSourceCode", + "license": ["https://spdx.org/licenses/AGPL-3.0", "https://spdx.org/licenses/MIT"], + "dateCreated": "2019-10-02", + "datePublished": "2020-01-01", + "name": "My Test Software", + "description": "This is a\ngreat piece of software", + })) + ); + cy.get('#importCodemeta').click(); + + cy.get('#name').should('have.value', 'My Test Software'); + cy.get('#description').should('have.value', 'This is a\ngreat piece of software'); + cy.get('#dateCreated').should('have.value', '2019-10-02'); + cy.get('#datePublished').should('have.value', '2020-01-01'); + cy.get('#license').should('have.value', ''); + cy.get("#selected-licenses").children().should('have.length', 2); + cy.get("#selected-licenses").children().eq(0).children().first().should('have.text', 'AGPL-3.0'); + cy.get("#selected-licenses").children().eq(1).children().first().should('have.text', 'MIT'); }); it('errors on invalid type', function() { cy.get('#codemetaText').then((elem) => elem.text(JSON.stringify({ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "foo", "name": "My Test Software", })) ); cy.get('#importCodemeta').click(); // Should still be imported as much as possible cy.get('#name').should('have.value', 'My Test Software'); // But must display an error cy.get('#errorMessage').should('have.text', 'Wrong document type: must be "SoftwareSourceCode"/"SoftwareApplication", not "foo"'); }); }); diff --git a/index.html b/index.html index 38f7cbe..0ae9b8b 100644 --- a/index.html +++ b/index.html @@ -1,355 +1,359 @@ CodeMeta generator

CodeMeta generator

Most fields are optional. Mandatory fields will be highlighted when generating Codemeta.

The software itself

the software title

- +
from SPDX licence list + +

+ +

Discoverability and citation


such as ISBNs, GTIN codes, UUIDs etc.. http://schema.org/identifier


grant funding software development


organization funding software development

Authors and contributors can be added below
Development community / tools


Run-time environment


Current version of the software


Additional Info


see www.repostatus.org for details

Authors
Contributors

Order of contributors does not matter.

codemeta.json:


   
diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index b267559..986e9a0 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -1,246 +1,258 @@ /** * Copyright (C) 2019-2020 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 */ "use strict"; const SPDX_PREFIX = 'https://spdx.org/licenses/'; function emptyToUndefined(v) { if (v == null || v == "") return undefined; else return v; } function getIfSet(query) { return emptyToUndefined(document.querySelector(query).value); } function setIfDefined(query, value) { if (value !== undefined) { document.querySelector(query).value = value; } } +function getLicenses() { + let selectedLicenses = Array.from(document.getElementById("selected-licenses").children); + return selectedLicenses.map(licenseDiv => SPDX_PREFIX + licenseDiv.children[0].innerText); +} + // Names of codemeta properties with a matching HTML field name const directCodemetaFields = [ 'codeRepository', 'contIntegration', 'dateCreated', 'datePublished', 'dateModified', 'downloadUrl', 'issueTracker', 'name', 'version', 'identifier', 'description', 'applicationCategory', 'releaseNotes', 'funding', 'developmentStatus', 'isPartOf', 'referencePublication' ]; const splittedCodemetaFields = [ ['keywords', ','], ['programmingLanguage', ','], ['runtimePlatform', ','], ['operatingSystem', ','], ['softwareRequirements', '\n'], ['relatedLink', '\n'], ] // Names of codemeta properties with a matching HTML field name, // in a Person object const directPersonCodemetaFields = [ 'givenName', 'familyName', 'email', 'affiliation', ]; function generateShortOrg(fieldName) { var affiliation = getIfSet(fieldName); if (affiliation !== undefined) { if (isUrl(affiliation)) { return { "@type": "Organization", "@id": affiliation, }; } else { return { "@type": "Organization", "name": affiliation, }; } } else { return undefined; } } function generatePerson(idPrefix) { var doc = { "@type": "Person", "@id": getIfSet(`#${idPrefix}_id`), } directPersonCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet(`#${idPrefix}_${item}`); }); doc["affiliation"] = generateShortOrg(`#${idPrefix}_affiliation`) return doc; } function generatePersons(prefix) { var persons = []; var nbPersons = getNbPersons(prefix); for (let personId = 1; personId <= nbPersons; personId++) { persons.push(generatePerson(`${prefix}_${personId}`)); } return persons; } function generateCodemeta() { var inputForm = document.querySelector('#inputForm'); var codemetaText, errorHTML; if (inputForm.checkValidity()) { var doc = { "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", }; - var license = getIfSet('#license') - if (license !== undefined) { - doc["license"] = SPDX_PREFIX + getIfSet('#license'); + let licenses = getLicenses(); + if (licenses.length > 0) { + doc["license"] = (licenses.length === 1) ? licenses[0] : licenses; } // Generate most fields directCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet('#' + item) }); doc["funder"] = generateShortOrg('#funder', doc["affiliation"]); // Generate simple fields parsed simply by splitting splittedCodemetaFields.forEach(function (item, index) { const id = item[0]; const separator = item[1]; const value = getIfSet('#' + id); if (value !== undefined) { doc[id] = value.split(separator).map(trimSpaces); } }); // Generate dynamic fields var authors = generatePersons('author'); if (authors.length > 0) { doc["author"] = authors; } var contributors = generatePersons('contributor'); if (contributors.length > 0) { doc["contributor"] = contributors; } codemetaText = JSON.stringify(doc, null, 4); errorHTML = ""; } else { codemetaText = ""; errorHTML = "invalid input (see error above)"; inputForm.reportValidity(); } document.querySelector('#codemetaText').innerText = codemetaText; setError(errorHTML); // Run validator on the exported value, for extra validation. // If this finds a validation, it means there is a bug in our code (either // generation or validation), and the generation MUST NOT generate an // invalid codemeta file, regardless of user input. if (codemetaText && !validateDocument(JSON.parse(codemetaText))) { alert('Bug detected! The data you wrote is correct; but for some reason, it seems we generated an invalid codemeta.json. Please report this bug at https://github.com/codemeta/codemeta-generator/issues/new and copy-paste the generated codemeta.json file. Thanks!'); } if (codemetaText) { // For restoring the form state on page reload sessionStorage.setItem('codemetaText', codemetaText); } } // Imports a single field (name or @id) from an Organization. function importShortOrg(fieldName, doc) { if (doc !== undefined) { // Use @id if set, else use name setIfDefined(fieldName, doc["name"]); setIfDefined(fieldName, doc["@id"]); } } function importPersons(prefix, legend, docs) { if (docs === undefined) { return; } docs.forEach(function (doc, index) { var personId = addPerson(prefix, legend); setIfDefined(`#${prefix}_${personId}_id`, doc['@id']); directPersonCodemetaFields.forEach(function (item, index) { setIfDefined(`#${prefix}_${personId}_${item}`, doc[item]); }); importShortOrg(`#${prefix}_${personId}_affiliation`, doc['affiliation']) }) } function importCodemeta() { var inputForm = document.querySelector('#inputForm'); var doc = parseAndValidateCodemeta(false); resetForm(); - if (doc['license'] !== undefined && doc['license'].indexOf(SPDX_PREFIX) == 0) { - var license = doc['license'].substring(SPDX_PREFIX.length); - document.querySelector('#license').value = 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); + }); } directCodemetaFields.forEach(function (item, index) { setIfDefined('#' + item, doc[item]); }); importShortOrg('#funder', doc["funder"]); // Import simple fields by joining on their separator splittedCodemetaFields.forEach(function (item, index) { const id = item[0]; const separator = item[1]; let value = doc[id]; if (value !== undefined) { if (Array.isArray(value)) { value = value.join(separator); } setIfDefined('#' + id, value); } }); importPersons('author', 'Author', doc['author']) importPersons('contributor', 'Contributor', doc['contributor']) } function loadStateFromStorage() { var codemetaText = sessionStorage.getItem('codemetaText') if (codemetaText) { document.querySelector('#codemetaText').innerText = codemetaText; importCodemeta(); } } diff --git a/js/dynamic_form.js b/js/dynamic_form.js index 15b2785..6ffcbeb 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -1,186 +1,188 @@ /** * Copyright (C) 2019 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 */ "use strict"; // List of all HTML fields in a Person fieldset. const personFields = [ 'givenName', 'familyName', 'email', 'id', 'affiliation', ]; function createPersonFieldset(personPrefix, legend) { // Creates a fieldset containing inputs for informations about a person var fieldset = document.createElement("fieldset") var moveButtons; fieldset.classList.add("person"); fieldset.classList.add("leafFieldset"); fieldset.id = personPrefix; - + fieldset.innerHTML = ` ${legend}

`; return fieldset; } function addPersonWithId(container, prefix, legend, id) { var personPrefix = `${prefix}_${id}`; var fieldset = createPersonFieldset(personPrefix, `${legend} #${id}`); container.appendChild(fieldset); document.querySelector(`#${personPrefix}_moveToLeft`) .addEventListener('click', () => movePerson(prefix, id, "left")); document.querySelector(`#${personPrefix}_moveToRight`) .addEventListener('click', () => movePerson(prefix, id, "right")); } function movePerson(prefix, id1, direction) { var nbPersons = getNbPersons(prefix); var id2; // 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; } } // 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 generateCodemeta(); } function addPerson(prefix, legend) { var container = document.querySelector(`#${prefix}_container`); var personId = getNbPersons(prefix) + 1; addPersonWithId(container, prefix, legend, personId); setNbPersons(prefix, personId); return personId; } function removePerson(prefix) { var personId = getNbPersons(prefix); document.querySelector(`#${prefix}_${personId}`).remove(); - setNbPersons(prefix, personId-1); + setNbPersons(prefix, personId - 1); } // 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`) for (let personId = 1; personId <= nbPersons; personId++) { addPersonWithId(personContainer, prefix, legend, personId); } } function removePersons(prefix) { var nbPersons = getNbPersons(prefix); var personContainer = document.querySelector(`#${prefix}_container`) for (let personId = 1; personId <= nbPersons; personId++) { removePerson(prefix) } } function resetForm() { removePersons('author'); removePersons('contributor'); + // Reset the list of selected licenses + document.getElementById("selected-licenses").innerHTML = ''; // Reset the form after deleting elements, so nbPersons doesn't get // reset before it's read. document.querySelector('#inputForm').reset(); } function fieldToLower(event) { event.target.value = event.target.value.toLowerCase(); } function initCallbacks() { document.querySelector('#license') - .addEventListener('change', validateLicense); + .addEventListener('keyup', validateLicense); document.querySelector('#generateCodemeta') .addEventListener('click', generateCodemeta); document.querySelector('#resetForm') .addEventListener('click', resetForm); document.querySelector('#validateCodemeta') .addEventListener('click', () => parseAndValidateCodemeta(true)); document.querySelector('#importCodemeta') .addEventListener('click', importCodemeta); document.querySelector('#inputForm') .addEventListener('change', generateCodemeta); document.querySelector('#developmentStatus') .addEventListener('change', fieldToLower); initPersons('author', 'Author'); initPersons('contributor', 'Contributor'); } diff --git a/js/fields_data.js b/js/fields_data.js index b60374e..de21e4a 100644 --- a/js/fields_data.js +++ b/js/fields_data.js @@ -1,51 +1,80 @@ /** * Copyright (C) 2019 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 */ "use strict"; var SPDX_LICENSES = null; var SPDX_LICENSE_IDS = null; function initSpdx() { var xhr = new XMLHttpRequest(); xhr.open('GET', './data/spdx/licenses.json', true); xhr.onload = function () { if (xhr.status === 200) { SPDX_LICENSES = JSON.parse(xhr.response)['licenses']; var datalist = document.getElementById('licenses'); SPDX_LICENSES.forEach(function (license) { var option = document.createElement('option'); option.value = license['licenseId']; option.label = `${license['licenseId']}: ${license['name']}`; datalist.appendChild(option); }); SPDX_LICENSE_IDS = SPDX_LICENSES.map(function (license) { return license['licenseId']; }) } } xhr.send(); } -function validateLicense() { +function insertLicenseElement(licenseId) { + let selectedLicenses = document.getElementById("selected-licenses"); + let newLicense = document.createElement("div"); + newLicense.className = "selected-license"; + newLicense.innerHTML = ` + ${licenseId} + + `; + + selectedLicenses.appendChild(newLicense); + return newLicense; +} + +function validateLicense(e) { + // continue only if Enter/Tab key is pressed + if (e.keyCode && e.keyCode !== 13 && e.keyCode !== 9) { + return; + } + // Note: For some reason e.keyCode is undefined when Enter/Tab key is pressed. + // Maybe it's because of the datalist. But the above condition should + // work in either case. + var licenseField = document.getElementById('license'); var license = licenseField.value; if (SPDX_LICENSE_IDS !== null && SPDX_LICENSE_IDS.indexOf(license) == -1) { licenseField.setCustomValidity('Unknown license id'); } else { + insertLicenseElement(license); + + licenseField.value = ""; licenseField.setCustomValidity(''); + generateCodemeta(); } +} +function removeLicense(btn) { + btn.parentElement.remove(); + generateCodemeta(); } function initFieldsData() { initSpdx(); } diff --git a/main.css b/main.css index dfa7c62..6204b47 100644 --- a/main.css +++ b/main.css @@ -1,78 +1,82 @@ /** * Copyright (C) 2019 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 */ /* This file contains the main CSS to make the form/application usable, * without being especially pretty. */ #noscriptError { color: red; } .person { display: inline-block; } #inputForm { max-width: 100%; display: flex; flex-wrap: wrap; } /* A fieldset that contains only label/input pairs */ .leafFieldset { flex: auto; } p input, p textarea { width: 100%; box-sizing: border-box; } .dynamicFields { width: 100%; } .dynamicFields .moveButtons { width: 100%; display: flex; justify-content: space-between; } #license { /* License names are long */ min-width: 20em; } #funding { /* Funding names are long */ min-width: 20em; } input[type=URL] { /* URLs are longer than the other fields */ min-width: 20em; } .field-description { color : rgb(100, 104, 103); font-size: small; } #codemetaText { width: 100%; min-height: 10em; border: 1px solid black; } #errorMessage { color: red; } input:invalid { color: red; } + +.selected-license { + margin: 2px; +}