diff --git a/index.html b/index.html index cdd289d..25820fe 100644 --- a/index.html +++ b/index.html @@ -1,346 +1,349 @@ 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

Order of authors does not matter.

Contributors

Order of contributors does not matter.

+

codemeta.json:


 
     
 
     
 
 
diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js
index 470cfae..90b2eae 100644
--- a/js/codemeta_generation.js
+++ b/js/codemeta_generation.js
@@ -1,248 +1,237 @@
 /**
  * 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;
     }
 }
 
 // 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');
         }
 
         // 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["contributors"] = 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);
 
     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 codemetaText = document.querySelector('#codemetaText').innerText;
-    var doc;
-
-    try {
-        doc = JSON.parse(codemetaText);
-    }
-    catch (e) {
-        setError(`Could not read codemeta document because it is not valid JSON (${e}). Check for missing or extra quote, colon, or bracket characters.`);
-        return;
-    }
+    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;
     }
 
     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'])
-
-    setError("");
 }
 
 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 01db5dc..8fef145 100644
--- a/js/dynamic_form.js
+++ b/js/dynamic_form.js
@@ -1,128 +1,131 @@
 /**
  * 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";
 
 function createPersonFieldset(personPrefix, legend) {
     // Creates a fieldset containing inputs for informations about a person
     var fieldset = document.createElement("fieldset")
     fieldset.classList.add("person");
     fieldset.classList.add("leafFieldset");
     fieldset.id = personPrefix;
     
     fieldset.innerHTML = `
         ${legend}
         

`; return fieldset; } function addPersonWithId(container, prefix, legend, id) { var fieldset = createPersonFieldset(`${prefix}_${id}`, `${legend} #${id}`); container.appendChild(fieldset); } 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); } // 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 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); 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/validation.js b/js/validation.js new file mode 100644 index 0000000..e40404e --- /dev/null +++ b/js/validation.js @@ -0,0 +1,404 @@ +/** + * 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 + */ + +/* + * Reads a Codemeta file and shows human-friendly errors on it. + * + * This validator intentionaly does not use a schema, in order to show errors + * that are easy to understand for users with no understanding of JSON-LD. + */ + + +function noValidation(fieldName, doc) { +} + +// Validates an URL or an array of URLs +function validateUrls(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.every((url) => { + if (typeof url != 'string') { + setError(`"${fieldName}" must be a list of URLs (or an URL), but it contains: ${JSON.stringify(url)}`); + return false; + } + else { + return validateUrl(fieldName, url); + } + }) + } + else if (typeof doc == 'string') { + return validateUrl(fieldName, doc); + } + else { + setError(`"${fieldName}" must be an URL (or a list of URLs), not: ${JSON.stringify(url)}`); + return false; + } +} + +// Validates a single URL +function validateUrl(fieldName, doc) { + if (!isUrl(doc)) { + setError(`Invalid URL in field "${fieldName}": ${JSON.stringify(doc)}`) + return false; + } + else { + return true; + } +} + +// Validates a single Text +function validateText(fieldName, doc) { + if (typeof doc != 'string') { + setError(`"${fieldName}" must be text, not ${JSON.stringify(doc)}`); + return false; + } + else { + return true; + } +} + +// Validates a Number or list of Number +function validateNumbers(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.every((subdoc) => { + if (typeof subdoc != 'number') { + setError(`"${fieldName}" must be an array of numbers (or a single number), but contains: ${JSON.stringify(subdoc)}`); + return false; + } + else { + return true; + } + }) + } + else if (typeof doc != 'number') { + setError(`"${fieldName}" must be a number or an array of numbers, not: ${JSON.stringify(subdoc)}`); + return false; + } + else { + return true; + } +} + +// Validates a single Boolean +function validateBoolean(fieldName, doc) { + if (typeof doc != 'boolean') { + setError(`"${fieldName}" must be a boolean (ie. "true" or "false"), not: ${JSON.stringify(subdoc)}`); + return false; + } + else { + return true; + } +} + +// Validates a single Date +function validateDate(fieldName, doc) { + let re = /^\d{4}-\d{2}-\d{2}$/; + if (typeof doc != 'string') { + setError(`"${fieldName}" must be a string representing a date, not: ${JSON.stringify(subdoc)}`); + return false; + } + else if (!doc.match(re)) { + setError(`"${fieldName}" must be date in the format YYYY-MM-DD, not: ${JSON.stringify(subdoc)}`); + return false; + } + else { + return true; + } +} + +// Validates a CreativeWork or an array of CreativeWork +function validateCreativeWorks(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.every((subdoc) => validateCreativeWork(fieldName, subdoc)); + } + else { + return validateCreativeWork(fieldName, doc); + } +} + +// Validates a single CreativeWork +function validateCreativeWork(fieldName, doc) { + if (!Array.isArray(doc) && typeof doc == 'object') { + var id = doc["id"] || doc["@id"]; + if (id !== undefined && !isUrl(id)) { + setError(`"${fieldName}" has an invalid URI as id: ${JSON.stringify(id)}"`); + return false; + } + + var type = doc["type"] || doc["@type"]; + if (type === undefined) { + if (id === undefined) { + setError(`"${fieldName}" must be a (list of) CreativeWork object, but it is missing a type/@type.`); + return false + } + else { + // FIXME: we have an @id but no @type, what should we do? + return true; + } + } + else if (type != "CreativeWork" || type != "schema:CreativeWork" || type != "http://schema.org/CreativeWork") { + // FIXME: is the first variant allowed? + setError(`"${fieldName}" must be a (list of) CreativeWork object, not ${JSON.stringify(doc)}`); + return false; + } + else { + return true; + } + + // TODO: check other fields + } + else if (typeof doc == 'string') { + if (!isUrl(doc)) { + setError(`"${fieldName}" must be an URI or CreativeWork object, not: ${JSON.stringify(id)}"`); + return false; + } + else { + return true; + } + } + else { + setError(`"${fieldName}" must be a CreativeWork object or URI, not ${JSON.stringify(doc)}`); + return false; + } +} + +// Validates a Person, Organization or an array of these +function validateActors(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.every((subdoc) => validateActor(fieldName, subdoc)); + } + else { + return validateActor(fieldName, doc); + } +} + +// Validates a Person or an array of Person +function validatePersons(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.every((subdoc) => validatePerson(fieldName, subdoc)); + } + else { + return validatePerson(fieldName, doc); + } +} + +// Validates an Organization or an array of Organization +function validateOrganizations(fieldName, doc) { + if (Array.isArray(doc)) { + return doc.forEach((subdoc) => validateOrganization(fieldName, subdoc)); + } + else { + return validateOrganization(fieldName, doc); + } +} + +// Validates a single Person or Organization +function validateActor(fieldName, doc) { + if (!Array.isArray(doc) && typeof doc == 'object') { + var id = doc["id"] || doc["@id"]; + if (id !== undefined && !isUrl(id)) { + setError(`"${fieldName}" has an invalid URI as id: ${JSON.stringify(id)}"`); + return false + } + + var type = doc["type"] || doc["@type"]; + if (type === undefined) { + if (id === undefined) { + setError(`"${fieldName}" must be a (list of) Person or Organization object(s), but it is missing a type/@type.`); + return false + } + else { + // FIXME: we have an @id but no @type, what should we do? + return true; + } + } + else if (type == "Person" || type == "schema:Person" || type == "codemeta:Person" || type == "http://schema.org/Person") { + // FIXME: is the first variant allowed? + return validatePerson(fieldName, doc); + } + else if (type == "Organization" || type == "schema:Organization" || type == "codemeta:Organization" || type == "http://schema.org/Organization") { + // FIXME: is the first variant allowed? + return validateOrganization(fieldName, doc); + } + else { + setError(`"${fieldName}" type must be a (list of) Person or Organization object(s), not ${type}`); + return false + } + } + else if (typeof doc == 'string') { + if (!isUrl(doc)) { + setError(`"${fieldName}" must be an URI or a Person or Organization object, not: ${JSON.stringify(id)}"`); + return false + } + else { + return true; + } + } + else { + setError(`"${fieldName}" must be a Person or Organization object or an URI, not ${JSON.stringify(doc)}`); + return false; + } +} + +// Validates a single Person object (assumes type/@type was already validated) +function validatePerson(fieldName, doc) { + // TODO + return true; +} + +// Validates a single Organization object (assumes type/@type was already validated) +function validateOrganization(fieldName, doc) { + // TODO + return true; +} + + +var softwareFieldValidators = { + "codeRepository": validateUrls, + "programmingLanguage": noValidation, + "runtimePlatform": noValidation, + "targetProduct": noValidation, // TODO: validate SoftwareApplication + "applicationCategory": noValidation, + "applicationSubCategory": noValidation, + "downloadUrl": validateUrls, + "fileSize": noValidation, // TODO + "installUrl": validateUrls, + "memoryRequirements": noValidation, + "operatingSystem": noValidation, + "permissions": noValidation, + "processorRequirements": noValidation, + "releaseNotes": noValidation, + "softwareHelp": validateCreativeWorks, + "softwareRequirements": noValidation, // TODO: validate SoftwareSourceCode + "softwareVersion": noValidation, // TODO? + "storageRequirements": noValidation, + "supportingData": noValidation, // TODO + "author": validateActors, + "citation": validateCreativeWorks, // TODO + "contributor": validateActors, + "copyrightHolder": validateActors, + "copyrightYear": validateNumbers, + "creator": validateActors, + "dateCreated": validateDate, + "dateModified": validateDate, + "datePublished": validateDate, + "editor": validatePersons, + "encoding": noValidation, + "fileFormat": noValidation, + "funder": validateActors, + "keywords": noValidation, + "license": validateCreativeWorks, // TODO + "producer": validateActors, + "provider": validateActors, + "publisher": validateActors, + "sponsor": validateActors, + "version": noValidation, + "isAccessibleForFree": validateBoolean, + "isPartOf": validateCreativeWorks, + "hasPart": validateCreativeWorks, + "position": noValidation, + "identifier": noValidation, // TODO + "description": validateText, + "name": validateText, + "sameAs": validateUrls, + "url": validateUrls, + "relatedLink": validateUrls, + + "softwareSuggestions": noValidation, // TODO: validate SoftwareSourceCode + "maintainer": validateActors, + "contIntegration": validateUrls, + "buildInstructions": validateUrls, + "developmentStatus": validateText, // TODO: use only repostatus strings? + "embargoDate": validateDate, + "funding": validateText, + "issueTracker": validateUrls, + "referencePublication": noValidation, // TODO? + "readme": validateUrls, +}; + +var personFieldValidators = { + "givenName": validateText, + "familyName": validateText, + "email": validateText, + "affiliation": validateOrganizations, + "identifier": validateUrls, + "name": validateText, +}; + + +function validateDocument(doc) { + if (!Array.isArray(doc) && typeof doc != 'object') { + setError("Document must be an object (starting and ending with { and }), not ${typeof doc}.") + return false; + } + var type = doc["type"] || doc["@type"]; + if (type === undefined) { + setError("Missing type (must be SoftwareSourceCode or SoftwareApplication).") + return false + } + else { + return Object.entries(doc).every((entry) => { + var fieldName = entry[0]; + var subdoc = entry[1]; + if (fieldName == "@context") { + if (subdoc == "https://doi.org/10.5063/schema/codemeta-2.0") { + return true; + } + else { + setError(`@context must be "https://doi.org/10.5063/schema/codemeta-2.0", not ${JSON.stringify(subdoc)}`); + return false; + } + } + else if (fieldName == "type" || fieldName == "@type") { + if (subdoc != "SoftwareSourceCode" && subdoc != "SoftwareApplication") { + setError(`Wrong type (must be SoftwareSourceCode or SoftwareApplication), not ${JSON.stringify(subdoc)}`) + return false + } + else { + return true; + } + } + else { + var validator = softwareFieldValidators[fieldName]; + if (validator === undefined) { + setError(`Invalid field "${fieldName}".`) + return false; + } + else { + return validator(fieldName, subdoc); + } + } + }); + } +} + + +function parseAndValidateCodemeta(showPopup) { + var codemetaText = document.querySelector('#codemetaText').innerText; + var doc; + + try { + doc = JSON.parse(codemetaText); + } + catch (e) { + setError(`Could not read codemeta document because it is not valid JSON (${e}). Check for missing or extra quote, colon, or bracket characters.`); + return; + } + + setError(""); + + var isValid = validateDocument(doc); + if (showPopup) { + if (isValid) { + alert('Document is valid!') + } + else { + alert('Document is invalid.'); + } + } + + return doc; +}