diff --git a/index.html b/index.html index cdd289d..25820fe 100644 --- a/index.html +++ b/index.html @@ -1,346 +1,349 @@
Most fields are optional. Mandatory fields will be highlighted when generating Codemeta.
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 = `
`; 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; +}