diff --git a/index.html b/index.html index 0ae9b8b..e451403 100644 --- a/index.html +++ b/index.html @@ -1,359 +1,381 @@ <!doctype html> <!-- 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 --> <html lang="en"> <head> <meta charset="utf-8"> <title>CodeMeta generator</title> <script src="./js/utils.js"></script> <script src="./js/fields_data.js"></script> <script src="./js/dynamic_form.js"></script> <script src="./js/codemeta_generation.js"></script> <script src="./js/validation/primitives.js"></script> <script src="./js/validation/things.js"></script> <script src="./js/validation/index.js"></script> <link rel="stylesheet" type="text/css" href="./main.css"> <link rel="stylesheet" type="text/css" href="./codemeta.css"> </head> <body> <header> <h1>CodeMeta generator</h1> </header> <main> <p>Most fields are optional. Mandatory fields will be highlighted when generating Codemeta.</p> + <form id="inputForm"> + + <fieldset id="fieldsetSoftwareItself" class="leafFieldset"> + <legend>Codemeta config</legend> + <p> + Version: + <select id="codemetaVersion"> + <option value="2" selected>2</option> + <option value="3">3</option> + </select> + </p> +</fieldset> + <noscript> <p id="noscriptError"> This application requires Javascript to show dynamic fields in the form, and generate a JSON file; but your browser does not support Javascript. If you cannot use a browser with Javascript support, you can try <a href="https://codemeta.github.io/tools/">one of the other available tools</a> or write the codemeta.json file directly. </p> </noscript> - - <form id="inputForm"> <fieldset id="fieldsetSoftwareItself" class="leafFieldset"> <legend>The software itself</legend> <p title="The name of the software"> <label for="name">Name</label> <input type="text" name="name" id="name" aria-describedby="name_descr" placeholder="My Software" required="required" /> <span class="field-description" id="name_descr">the software title</span> </p> <p title="a brief description of the software"> <label for="description">Description</label> <textarea rows="4" cols="50" name="description" id="description" placeholder="My Software computes ephemerides and orbit propagation. It has been developed from early ´80." ></textarea> </p> <p title="The date on which the software was created."> <label for="dateCreated">Creation date</label> <input type="text" name="dateCreated" id="dateCreated" placeholder="YYYY-MM-DD" pattern="\d{4}-\d{2}-\d{2}" /> </p> <p title="Date of first publication."> <label for="datePublished">First release date</label> <input type="text" name="datePublished" id="datePublished" placeholder="YYYY-MM-DD" pattern="\d{4}-\d{2}-\d{2}" /> </p> <p> <label for="license">License(s)</label> <input list="licenses" name="license" id="license" aria-describedby="licenses_descr"> <!-- TODO: insert placeholder --> <datalist id="licenses"> </datalist> <!-- This datalist is be filled automatically --> <br /> <span class="field-description" id="licenses_descr">from <a href="https://spdx.org/license-list">SPDX licence list</a></span> <div id="selected-licenses"> <!-- This div is to be filled as the user selects licenses --> </div> </p> </fieldset> <fieldset id="fieldsetDiscoverabilityAndCitation" class="leafFieldset"> <legend>Discoverability and citation</legend> <p title="Unique identifier"> <label for="identifier">Unique identifier</label> <input type="text" name="identifier" id="identifier" placeholder="10.151.xxxxx" aria-describedby="identifier_descr" /> <br /> <span class="field-description" id="identifier_descr"> such as ISBNs, GTIN codes, UUIDs etc.. <a href="http://schema.org/identifier">http://schema.org/identifier</a> </span> </p> <!-- TODO:define better I looked at the schema.org definition of identifier (https://schema.org/identifier), it can be text, url or PropertyValue. Used as follows in data representation with microdata: <div property="identifier" typeof="PropertyValue"> <span property="propertyID">DOI</span>: <span property="value">10.151.xxxxx</span> </div> we can use that with identifier-type and identifier-value to have a clearer idea of what needs to be in the input. --> <p title="Type of the software application"> <label for="applicationCategory">Application category</label> <input type="text" name="applicationCategory" id="applicationCategory" placeholder="Astronomy" /> </p> <p title="Comma-separated list of keywords"> <label for="keywords">Keywords</label> <input type="text" name="keywords" id="keywords" placeholder="ephemerides, orbit, astronomy" /> </p> <p title="Funding / grant"> <label for="funding">Funding</label> <input type="text" name="funding" id="funding" aria-describedby="funding_descr" placeholder="PRA_2018_73"/> <br /> <span class="field-description" id="funding_descr">grant funding software development</span> </p> <p title="Funding / organization"> <label for="funder">Funder</label> <input type="text" name="funder" id="funder" aria-describedby="funder_descr" placeholder="Università di Pisa"/> <br /> <span class="field-description" id="funder_descr">organization funding software development</span> </p> Authors and contributors can be added below </fieldset> <fieldset id="fieldsetDevelopmentCommunity" class="leafFieldset"> <legend>Development community / tools</legend> <p title="Link to the repository where the un-compiled, human readable code and related code is located (SVN, Git, GitHub, CodePlex, institutional GitLab instance, etc.)."> <label for="codeRepository">Code repository</label> <input type="URL" name="codeRepository" id="codeRepository" placeholder="git+https://github.com/You/RepoName.git" /> </p> <p title="Link to continuous integration service (Travis-CI, Gitlab CI, etc.)."> <label for="contIntegration">Continuous integration</label> <input type="URL" name="contIntegration" id="contIntegration" placeholder="https://travis-ci.org/You/RepoName" /> </p> <p title="Link to a place for users/developpers to report and manage bugs (JIRA, GitHub issues, etc.)."> <label for="issueTracker">Issue tracker</label> <input type="URL" name="issueTracker" id="issueTracker" placeholder="https://github.com/You/RepoName/issues" /> </p> <p title="Related document, software, tools"> <label for="relatedLink">Related links</label> <br /> <textarea rows="4" cols="50" name="relatedLink" id="relatedLink"></textarea> </fieldset> <fieldset id="fieldsetRuntime" class="leafFieldset"> <legend>Run-time environment</legend> <p title="Programming Languages, separated by commas"> <label for="programmingLanguage">Programming Language</label> <input type="text" name="programmingLanguage" id="programmingLanguage" placeholder="C#, Java, Python 3" /> </p> <p title="Runtime Platforms, separated by commas"> <label for="runtimePlatform">Runtime Platform</label> <input type="text" name="runtimePlatform" id="runtimePlatform" placeholder=".NET, JVM" /> </p> <p title="Operating Systems, separated by commas"> <label for="operatingSystem">Operating System</label> <input type="text" name="operatingSystem" id="operatingSystem" placeholder="Android 1.6, Linux, Windows, macOS" /> </p> <p title="Required software to run/use this one."> <label for="softwareRequirements">Other software requirements</label> <br /> <textarea rows="4" cols="50" name="softwareRequirements" id="softwareRequirements" placeholder= "Python 3.4 https://github.com/psf/requests"></textarea> </fieldset> <fieldset id="fieldsetCurrentVersion" class="leafFieldset"> <legend>Current version of the software</legend> <p title="Version number of the software"> <label for="version">Version number</label> <input type="text" name="version" id="version" placeholder="1.0.0" /> </p> <p title="The date on which the software was most recently modified."> <label for="dateModified">Release date</label> <input type="text" name="dateModified" id="dateModified" placeholder="YYYY-MM-DD" pattern="\d{4}-\d{2}-\d{2}" /> </p> <p title="Download link"> <label for="downloadUrl">Download URL</label> <input type="URL" name="downloadUrl" id="downloadUrl" placeholder="https://example.org/MySoftware.tar.gz" /> </p> <p title="a brief description of the software"> <label for="releaseNotes">Release notes</label> <br /> <textarea rows="4" cols="50" name="releaseNotes" id="releaseNotes" placeholder= "Change log: this and that; Bugfixes: that and this." ></textarea> </p> <!--TODO: referencePublication as ScholarlyArticle array --> </fieldset> + <fieldset id="fieldsetSourceCode" class="leafFieldset"> + <legend>Source code</legend> + + <p title="Reference to the source code"> + <label for="isSourceCodeOf">Is Source Code of</label> + <input type="URL" name="isSourceCodeOf" id="isSourceCodeOf" + placeholder="https://codemeta.github.io" /> + </p> + + </fieldset> + <fieldset id="fieldsetAdditionalInfo" class="leafFieldset"> <legend>Additional Info</legend> <p title="Scholarly article describing this software"> <label for="referencePublication">Reference Publication</label> <input type="URL" name="referencePublication" id="referencePublication" placeholder="https://doi.org/10.1000/xyz123" /> </p> <p title="Development Status"> <label for="developmentStatus">Development Status</label> <datalist id="developmentStatuses"> <option value="concept"> <option value="wip"> <option value="suspended"> <option value="abandoned"> <option value="active"> <option value="inactive"> <option value="unsupported"> <option value="moved"> </datalist> <input list="developmentStatuses" id="developmentStatus" aria-describedby="developmentStatuses_descr" pattern="concept|wip|suspended|abandoned|active|inactive|unsupported|moved"> <br /> <span class="field-description" id="developmentStatuses_descr"> see <a href="http://www.repostatus.org">www.repostatus.org</a> for details </span> </p> <p title="Part of"> <label for="isPartOf">Is part of</label> <input type="URL" name="isPartOf" id="isPartOf" placeholder="http://The.Bigger.Framework.org" /> </p> </fieldset> <div class="dynamicFields"> <fieldset class="persons" id="author_container"> <legend>Authors</legend> <input type="hidden" id="author_nb" value="0" /> <div id="addRemoveAuthor"> <input type="button" id="author_add" value="Add one" onclick="addPerson('author', 'Author');" /> <input type="button" id="author_remove" value="Remove last" onclick="removePerson('author');" /> </div> </fieldset> <fieldset class="persons" id="contributor_container"> <legend>Contributors</legend> <p>Order of contributors does not matter.</p> <input type="hidden" id="contributor_nb" value="0" /> <div id="addRemoveContributor"> <input type="button" id="contributor_add" value="Add one" onclick="addPerson('contributor', 'Contributor');" /> <input type="button" id="contributor_remove" value="Remove last" onclick="removePerson('contributor');" /> </div> </fieldset> </div> </form> <form> <input type="button" id="generateCodemeta" value="Generate codemeta.json" title="Creates a codemeta.json file below, from the information provided above." /> <input type="button" id="resetForm" value="Reset form" title="Erases all fields." /> <input type="button" id="validateCodemeta" value="Validate codemeta.json" title="Checks the codemeta.json file below is valid, and displays errors." /> <input type="button" id="importCodemeta" value="Import codemeta.json" title="Fills the fields above based on the codemeta.json file below." /> </form> <p id="errorMessage"> </p> <p>codemeta.json:</p> <pre contentEditable="true" id="codemetaText"></pre> </main> <footer> <p style="text-align:center;"> Do you want to improve this tool ? Check out the <a href="https://github.com/codemeta/codemeta-generator"> CodeMeta-generator repository</a> <br /> Join the <a href="https://github.com/codemeta/codemeta">CodeMeta community</a> discussion <br /> The CodeMeta vocabulary - <a href="https://doi.org/10.5063/schema/codemeta-2.0">v2.0</a> </p> <h2 style="text-align:right;">Contributed by</h2> <p style="text-align:right;"> <a href="https://www.softwareheritage.org/save-and-reference-research-software/"> <img alt="Software Heritage" src="https://annex.softwareheritage.org/public/logo/software-heritage-logo-title-motto.svg" width="300"> </a> </p> </footer> <script> initFieldsData(); initCallbacks(); loadStateFromStorage(); </script> </body> </html> diff --git a/js/codemeta_generation.js b/js/codemeta_generation.js index 986e9a0..9d1804b 100644 --- a/js/codemeta_generation.js +++ b/js/codemeta_generation.js @@ -1,258 +1,314 @@ /** * 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 directCodemetaFieldsV2 = { + 'codeRepository': '#codeRepository', + 'contIntegration': '#contIntegration', + 'dateCreated': '#dateCreated', + 'datePublished': '#datePublished', + 'dateModified': '#dateModified', + 'downloadUrl': '#downloadUrl', + 'issueTracker': '#issueTracker', + 'name': '#name', + 'version': '#version', + 'identifier': '#identifier', + 'description': '#description', + 'applicationCategory': '#applicationCategory', + 'releaseNotes': '#releaseNotes', + 'funding': '#funding', + 'developmentStatus': '#funding', + 'isPartOf': '#isPartOf', + 'referencePublication': '#referencePublication' +}; + +const directCodemetaFieldsV3 = { + ...directCodemetaFieldsV2, + // 'hasSourceCode': '#hasSourceCode', -> Generator is for SoftwareSource code not SoftwareApplication + 'isSourceCodeOf': '#isSourceCodeOf', + 'continuousIntegration': '#contIntegration', + // 'embargoEndDate': '#embargoDate' -> Not in use for v2. Not required for v3 either. +}; + 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`), + "@id": getIfSet(`#${idPrefix}_id`) || `_:${idPrefix}`, } directPersonCodemetaFields.forEach(function (item, index) { doc[item] = getIfSet(`#${idPrefix}_${item}`); }); - doc["affiliation"] = generateShortOrg(`#${idPrefix}_affiliation`) + 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}`)); + const personIdPrefix = `${prefix}_${personId}`; + persons.push(generatePerson(personIdPrefix)); + + const roles = getIfSet(`#${personIdPrefix}_roles`); + if (roles) { + for (let role of roles.split(",")) { + const [roleName, startDate, endDate] = role.split(":"); + persons.push({ + "@type": "Role", + "roleName": roleName, + "startDate": startDate, + "endDate": endDate, + "author": { + "@id": getIfSet(`#${personIdPrefix}_id`) || `_:${personIdPrefix}`, + } + }); + } + } } return persons; } function generateCodemeta() { var inputForm = document.querySelector('#inputForm'); var codemetaText, errorHTML; if (inputForm.checkValidity()) { + const isCodemetaV3 = inputForm.querySelector("#codemetaVersion").value === '3'; + var doc = { - "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "@context": `https://doi.org/10.5063/schema/codemeta-${isCodemetaV3 ? '3' : '2'}.0`, "@type": "SoftwareSourceCode", }; 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) - }); + const directFields = isCodemetaV3 ? directCodemetaFieldsV3 : directCodemetaFieldsV2; + for (const key in directFields) { + doc[key] = getIfSet(directFields[key]); + } 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; } + const roles = {}; + for (const doc of docs) { + if (doc['@type'] === 'Role') { + let authorId = doc['author']['@id']; + authorId = isBlankNodeId(authorId) ? authorId.substring(2) : authorId; + if (!!authorId && roles[authorId] === undefined) { + roles[authorId] = []; + } + roles[authorId].push(`${doc['roleName']}:${doc['startDate']}:${doc['endDate']}`); + } + } + + console.log('Roles are', roles); + docs.forEach(function (doc, index) { - var personId = addPerson(prefix, legend); + if (doc['@type'] === 'Role') { + return; + } - setIfDefined(`#${prefix}_${personId}_id`, doc['@id']); + const personId = addPerson(prefix, legend); + const personDocId = isBlankNodeId(doc['@id']) ? '' : doc['@id']; + + setIfDefined(`#${prefix}_${personId}_id`, personDocId); directPersonCodemetaFields.forEach(function (item, index) { setIfDefined(`#${prefix}_${personId}_${item}`, doc[item]); }); importShortOrg(`#${prefix}_${personId}_affiliation`, doc['affiliation']) }) + + for (const authorId in roles) { + setIfDefined(`#${authorId}_roles`, roles[authorId].join(', ')); + } } function importCodemeta() { var inputForm = document.querySelector('#inputForm'); var doc = parseAndValidateCodemeta(false); resetForm(); 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]); - }); + const isCodemetaV3 = doc['@context'] === 'https://doi.org/10.5063/schema/codemeta-3.0'; + + const directCodemetaField = (isCodemetaV3 ? directCodemetaFieldsV3 : directCodemetaFieldsV2); + for (const key in directCodemetaField) { + setIfDefined(directCodemetaField[key], doc[key]); + } + 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 5af7c46..9e6da59 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -1,188 +1,193 @@ /** * 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>${legend}</legend> <div class="moveButtons"> <input type="button" id="${personPrefix}_moveToLeft" value="<" class="moveToLeft" title="Moves this person to the left." /> <input type="button" id="${personPrefix}_moveToRight" value=">" class="moveToRight" title="Moves this person to the right." /> </div> <p> <label for="${personPrefix}_givenName">Given name</label> <input type="text" id="${personPrefix}_givenName" name="${personPrefix}_givenName" placeholder="Jane" required="true" /> </p> <p> <label for="${personPrefix}_familyName">Family name</label> <input type="text" id="${personPrefix}_familyName" name="${personPrefix}_familyName" placeholder="Doe" /> </p> <p> <label for="${personPrefix}_email">E-mail address</label> <input type="email" id="${personPrefix}_email" name="${personPrefix}_email" placeholder="jane.doe@example.org" /> </p> <p> <label for="${personPrefix}_id">URI</label> <input type="url" id="${personPrefix}_id" name="${personPrefix}_id" placeholder="http://orcid.org/0000-0002-1825-0097" /> </p> <p> <label for="${personPrefix}_affiliation">Affiliation</label> <input type="text" id="${personPrefix}_affiliation" name="${personPrefix}_affiliation" placeholder="Department of Computer Science, University of Pisa" /> </p> + <p> + <label for="${personPrefix}_roles">Roles</label> + <input type="text" id="${personPrefix}_roles" name="${personPrefix}_roles" + placeholder="Coding:2022:2023, Documentation:2022:2023" /> + </p> `; 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); } // 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); 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/utils.js b/js/utils.js index 8d203cb..cefd605 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,36 +1,40 @@ /** * 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 getNbPersons(prefix) { var nbField = document.querySelector(`#${prefix}_nb`); return parseInt(nbField.value, 10); } function setNbPersons(prefix, nb) { var nbField = document.querySelector(`#${prefix}_nb`); nbField.value = nb; } function setError(msg) { document.querySelector("#errorMessage").innerHTML = msg; } function trimSpaces(s) { return s.replace(/^\s+|\s+$/g, ''); } // From https://stackoverflow.com/a/43467144 function isUrl(s) { try { new URL(s); return true; } catch (e) { return false; } } + +function isBlankNodeId(s) { + return s.startsWith("_:"); +} diff --git a/js/validation/index.js b/js/validation/index.js index 546fab0..d1bc848 100644 --- a/js/validation/index.js +++ b/js/validation/index.js @@ -1,104 +1,105 @@ /** * 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 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; } // TODO: validate id/@id context = doc["@context"]; - if (context == "https://doi.org/10.5063/schema/codemeta-2.0") { + const isCodemetaV3 = context === "https://doi.org/10.5063/schema/codemeta-3.0"; + if (context === "https://doi.org/10.5063/schema/codemeta-2.0" || context === "https://doi.org/10.5063/schema/codemeta-3.0") { // Correct } else if (Array.isArray(context) && context.includes("https://doi.org/10.5063/schema/codemeta-2.0")) { if (context.length !== 1) { setError(`Multiple values in @context are not supported (@context should be "https://doi.org/10.5063/schema/codemeta-2.0", not ${JSON.stringify(context)})`); return false; } } else { setError(`@context must be "https://doi.org/10.5063/schema/codemeta-2.0", not ${JSON.stringify(context)}`); return false; } // TODO: check there is either type or @type but not both var type = getDocumentType(doc); if (type === undefined) { setError("Missing type (must be SoftwareSourceCode or SoftwareApplication).") return false; } else if (!isCompactTypeEqual(type, "SoftwareSourceCode") && !isCompactTypeEqual(type, "SoftwareApplication")) { // Check this before other fields, as a wrong type error is more // understandable than "invalid field". setError(`Wrong document type: must be "SoftwareSourceCode"/"SoftwareApplication", not ${JSON.stringify(type)}`) return false; } else { return Object.entries(doc).every((entry) => { var fieldName = entry[0]; var subdoc = entry[1]; if (fieldName == "@context") { // Was checked before return true; } else if (fieldName == "type" || fieldName == "@type") { // Was checked before return true; } else { - var validator = softwareFieldValidators[fieldName]; + var validator = (isCodemetaV3 ? softwareFieldValidatorsV3: softwareFieldValidatorsV2)[fieldName]; if (validator === undefined) { // TODO: find if it's a field that belongs to another type, // and suggest that to the user setError(`Unknown 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; } diff --git a/js/validation/primitives.js b/js/validation/primitives.js index 2dc1cd0..52ea404 100644 --- a/js/validation/primitives.js +++ b/js/validation/primitives.js @@ -1,146 +1,146 @@ /** * 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 */ /* * Validators for native schema.org data types. */ // Validates an URL or an array of URLs function validateUrls(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of URLs (or a single URL), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be an URL (or a list of URLs), not: ${JSON.stringify(subdoc)}`); } return false; } else { return validateUrl(fieldName, subdoc); } }); } // Validates a single URL function validateUrl(fieldName, doc) { - if (!isUrl(doc)) { + if (!isBlankNodeId(doc) && !isUrl(doc)) { setError(`Invalid URL in field "${fieldName}": ${JSON.stringify(doc)}`) return false; } else { return true; } } // Validates a Text/URL or an array of Texts/URLs function validateTextsOrUrls(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of texts/URLs (or a single text/URL), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a text/URL (or a list of texts/URLs), not: ${JSON.stringify(subdoc)}`); } return false; } else { return true; } }); } // Validates a Text or an array of Texts function validateTexts(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'string') { if (inList) { setError(`"${fieldName}" must be a list of texts (or a single text), but it contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a text (or a list of texts), not: ${JSON.stringify(subdoc)}`); } 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) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { if (typeof subdoc != 'number') { if (inList) { setError(`"${fieldName}" must be an array of numbers (or a single number), but contains: ${JSON.stringify(subdoc)}`); } else { setError(`"${fieldName}" must be a number or an array of numbers, not: ${JSON.stringify(subdoc)}`); } return false; } else { return true; } }); } // Validates a single Text or Number function validateNumberOrText(fieldName, doc) { if (typeof doc == 'string') { return true; } else if (typeof doc == 'number') { return true; } else { setError(`"${fieldName}" must be text or a number, not ${JSON.stringify(doc)}`); return false; } } // 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 date, not ${JSON.stringify(doc)}`); return false; } else if (!doc.match(re)) { setError(`"${fieldName}" must be a date in the format YYYY-MM-DD, not ${JSON.stringify(doc)}`); return false; } else { return true; } } diff --git a/js/validation/things.js b/js/validation/things.js index 496cd01..ff8640a 100644 --- a/js/validation/things.js +++ b/js/validation/things.js @@ -1,309 +1,320 @@ /** * Copyright (C) 2020-2021 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 */ /* * Validators for codemeta objects derived from http://schema.org/Thing. */ function getDocumentType(doc) { // TODO: check there is at most one. // FIXME: is the last variant allowed? return doc["type"] || doc["@type"] || doc["codemeta:type"] } function isCompactTypeEqual(type, compactedType) { // FIXME: are all variants allowed? return (type == `${compactedType}` || type == `schema:${compactedType}` || type == `codemeta:${compactedType}` || type == `http://schema.org/${compactedType}` ); } function noValidation(fieldName, doc) { return true; } // Validates subtypes of Thing, or URIs // // typeFieldValidators is a map: {type => {fieldName => fieldValidator}} function validateThingOrId(parentFieldName, typeFieldValidators, doc) { var acceptedTypesString = Object.keys(typeFieldValidators).join('/'); if (typeof doc == 'string') { if (!isUrl(doc)) { setError(`"${parentFieldName}" must be an URL or a ${acceptedTypesString} object, not: ${JSON.stringify(doc)}`); return false; } else { return true; } } else if (!Array.isArray(doc) && typeof doc == 'object') { return validateThing(parentFieldName, typeFieldValidators, doc); } else { setError(`"${parentFieldName}" must be a ${acceptedTypesString} object or URI, not ${JSON.stringify(doc)}`); return false; } } // Validates subtypes of Thing // // typeFieldValidators is a map: {type => {fieldName => fieldValidator}} function validateThing(parentFieldName, typeFieldValidators, doc) { // TODO: check there is either id or @id but not both // TODO: check there is either type or @type but not both var acceptedTypesString = Object.keys(typeFieldValidators).join('/'); var documentType = getDocumentType(doc); var id = doc["id"] || doc["@id"]; - if (id !== undefined && !isUrl(id)) { + if (id !== undefined && !isBlankNodeId("_:") && !isUrl(id)) { setError(`"${fieldName}" has an invalid URI as id: ${JSON.stringify(id)}"`); return false; } if (documentType === undefined) { if (id === undefined) { setError(`"${parentFieldName}" must be a (list of) ${acceptedTypesString} object(s) or an URI, but is missing a type/@type.`); return false; } else { // FIXME: we have an @id but no @type, what should we do? return true; } } for (expectedType in typeFieldValidators) { if (isCompactTypeEqual(documentType, expectedType)) { var fieldValidators = typeFieldValidators[expectedType]; return Object.entries(doc).every((entry) => { var fieldName = entry[0]; var subdoc = entry[1]; if (fieldName == "type" || fieldName == "@type") { // Was checked before return true; } else { var validator = fieldValidators[fieldName]; if (validator === undefined) { // TODO: find if it's a field that belongs to another type, // and suggest that to the user setError(`Unknown field "${fieldName}" in "${parentFieldName}".`) return false; } else { return validator(fieldName, subdoc); } } }); } } setError(`"${parentFieldName}" type must be a (list of) ${acceptedTypesString} object(s), not ${JSON.stringify(documentType)}`); return false; } // Helper function to validate a field is either X or a list of X. function validateListOrSingle(fieldName, doc, validator) { if (Array.isArray(doc)) { return doc.every((subdoc) => validator(subdoc, true)); } else { return validator(doc, false); } } // Validates a CreativeWork or an array of CreativeWork function validateCreativeWorks(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateCreativeWork(fieldName, subdoc); }); } // Validates a single CreativeWork function validateCreativeWork(fieldName, doc) { return validateThingOrId(fieldName, { "CreativeWork": creativeWorkFieldValidators, - "SoftwareSourceCode": softwareFieldValidators, - "SoftwareApplication": softwareFieldValidators, + "SoftwareSourceCode": softwareFieldValidatorsV2, + "SoftwareApplication": softwareFieldValidatorsV2, }, doc); } // Validates a Person, Organization or an array of these function validateActors(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateActor(fieldName, subdoc); }); } +// Validates an author or contributor +function validateAuthorOrContributor(fieldName, doc) { + // TODO: Allow parsing Role? + return validateActors(fieldName, doc); +} + // Validates a Person or an array of Person function validatePersons(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validatePerson(fieldName, subdoc); }); } // Validates an Organization or an array of Organization function validateOrganizations(fieldName, doc) { return validateListOrSingle(fieldName, doc, (subdoc, inList) => { return validateOrganization(fieldName, subdoc); }); } // Validates a single Person or Organization function validateActor(fieldName, doc) { return validateThingOrId(fieldName, { "Person": personFieldValidators, "Organization": organizationFieldValidators, }, doc); } // Validates a single Person object function validatePerson(fieldName, doc) { return validateThingOrId(fieldName, {"Person": personFieldValidators}, doc); } // Validates a single Organization object function validateOrganization(fieldName, doc) { return validateThingOrId(fieldName, {"Organization": organizationFieldValidators}, doc); } -var softwareFieldValidators = { +var softwareFieldValidatorsV2 = { "@id": validateUrl, "id": validateUrl, "codeRepository": validateUrls, "programmingLanguage": noValidation, "runtimePlatform": validateTexts, "targetProduct": noValidation, // TODO: validate SoftwareApplication "applicationCategory": validateTextsOrUrls, "applicationSubCategory": validateTextsOrUrls, "downloadUrl": validateUrls, "fileSize": validateText, // TODO "installUrl": validateUrls, "memoryRequirements": validateTextsOrUrls, "operatingSystem": validateTexts, "permissions": validateTexts, "processorRequirements": validateTexts, "releaseNotes": validateTextsOrUrls, "softwareHelp": validateCreativeWorks, "softwareRequirements": noValidation, // TODO: validate SoftwareSourceCode "softwareVersion": validateText, // TODO? "storageRequirements": validateTextsOrUrls, "supportingData": noValidation, // TODO "author": validateActors, "citation": validateCreativeWorks, // TODO "contributor": validateActors, "copyrightHolder": validateActors, "copyrightYear": validateNumbers, "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master "dateCreated": validateDate, "dateModified": validateDate, "datePublished": validateDate, "editor": validatePersons, "encoding": noValidation, "fileFormat": validateTextsOrUrls, "funder": validateActors, // TODO: may be other types "keywords": validateTexts, "license": validateCreativeWorks, "producer": validateActors, "provider": validateActors, "publisher": validateActors, "sponsor": validateActors, "version": validateNumberOrText, "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 softwareFieldValidatorsV3 = { + ...softwareFieldValidatorsV2, + "isSourceCodeOf": validateUrls, +} + var creativeWorkFieldValidators = { "@id": validateUrl, "id": validateUrl, "author": validateActors, "citation": validateCreativeWorks, // TODO "contributor": validateActors, "copyrightHolder": validateActors, "copyrightYear": validateNumbers, "creator": validateActors, // TODO: still in codemeta 2.0, but removed from master "dateCreated": validateDate, "dateModified": validateDate, "datePublished": validateDate, "editor": validatePersons, "encoding": noValidation, "funder": validateActors, // TODO: may be other types "keywords": validateTexts, "license": validateCreativeWorks, "producer": validateActors, "provider": validateActors, "publisher": validateActors, "sponsor": validateActors, "version": validateNumberOrText, "isAccessibleForFree": validateBoolean, "isPartOf": validateCreativeWorks, "hasPart": validateCreativeWorks, "position": noValidation, "identifier": noValidation, // TODO "description": validateText, "name": validateText, "sameAs": validateUrls, "url": validateUrls, }; var personFieldValidators = { "@id": validateUrl, "id": validateUrl, "givenName": validateText, "familyName": validateText, "email": validateText, "affiliation": validateOrganizations, "identifier": validateUrls, "name": validateText, // TODO: this is technically valid, but should be allowed here? "url": validateUrls, }; var organizationFieldValidators = { "@id": validateUrl, "id": validateUrl, "email": validateText, "identifier": validateUrls, "name": validateText, "address": validateText, "sponsor": validateActors, "funder": validateActors, // TODO: may be other types "isPartOf": validateOrganizations, "url": validateUrls, // TODO: add more? };