diff --git a/Makefile b/Makefile index 5908d35ed1e..61fe245c35a 100755 --- a/Makefile +++ b/Makefile @@ -262,3 +262,6 @@ electrophysiology_browser: $(filter modules/electrophysiology_browser/%,$(MOFILE dicom_archive: $(filter modules/dicom_archive/%,$(MOFILES)) $(filter modules/dicom_archive/%,$(I18NJSONFILES)) target=dicom_archive npm run compile + +redcap: $(filter modules/redcap/%,$(MOFILES)) $(filter modules/redcap/%,$(I18NJSONFILES)) + target=redcap npm run compile \ No newline at end of file diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql index 19fb5836987..7e4d8658c78 100644 --- a/SQL/0000-00-00-schema.sql +++ b/SQL/0000-00-00-schema.sql @@ -2708,3 +2708,26 @@ CREATE TABLE `redcap_notification` ( PRIMARY KEY (`id`), KEY `i_redcap_notif_received_dt` (`received_dt`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `redcap_dictionary` ( + ID int(10) unsigned NOT NULL auto_increment, + InstrumentName varchar(255), + FieldName varchar(255) NOT NULL, + FieldRequired boolean NOT NULL, + FieldType varchar(20) NOT NULL, + FieldLabel text NOT NULL, + SectionHeader text, + Choices text, + FieldNote text, + TextValidationType varchar(50), + TextValidationMin varchar(50), + TextValidationMax varchar(50), + Identifier text, + BranchingLogic text, + CustomAlignment varchar(10), + QuestionNumber text, + MatrixGroupName varchar(100), + MatrixRanking varchar(50), + FieldAnnotation text, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='All REDCap instrument fields and variables'; diff --git a/SQL/0000-00-02-Permission.sql b/SQL/0000-00-02-Permission.sql index d9d594d6729..3234a6e7632 100644 --- a/SQL/0000-00-02-Permission.sql +++ b/SQL/0000-00-02-Permission.sql @@ -166,6 +166,7 @@ INSERT INTO `permissions` (code, description, moduleID, categoryID) VALUES ('dicom_archive_nosessionid', 'DICOMs with no session ID', (SELECT ID FROM modules WHERE Name='dicom_archive'),2), ('dicom_archive_view_ownsites', 'DICOMs - Own Sites', (SELECT ID FROM modules WHERE Name='dicom_archive'),2), ('view_instrument_data', 'Data', (SELECT ID FROM modules WHERE Name = 'instruments'),2), + ('redcap_ui_view','REDCap GUI - View',(SELECT ID FROM modules WHERE Name ='redcap'),2), ('biobank_specimen_view','View Specimen Data',(SELECT ID FROM modules WHERE Name='biobank'), '2'), ('biobank_specimen_create','Create Specimens',(SELECT ID FROM modules WHERE Name='biobank'), '2'), ('biobank_specimen_update','Process Specimens',(SELECT ID FROM modules WHERE Name='biobank'), '2'), @@ -289,7 +290,8 @@ INSERT INTO `perm_perm_action_rel` (permID, actionID) VALUES ((SELECT permID FROM permissions WHERE code = 'biobank_fullprojectaccess'),1), ((SELECT permID FROM permissions WHERE code = 'view_instrument_data'),1), ((SELECT permID FROM permissions WHERE code = 'dqt_view'),1), - ((SELECT permID FROM permissions WHERE code = 'dqt_view'),8); + ((SELECT permID FROM permissions WHERE code = 'dqt_view'),8), + ((SELECT permID FROM permissions WHERE code = 'redcap_ui_view'),1); -- permissions for each notification module diff --git a/SQL/9999-99-99-drop_tables.sql b/SQL/9999-99-99-drop_tables.sql index 528474db560..079b4359784 100644 --- a/SQL/9999-99-99-drop_tables.sql +++ b/SQL/9999-99-99-drop_tables.sql @@ -103,6 +103,7 @@ DROP TABLE IF EXISTS `modules`; -- 0000-00-00-schema.sql DROP TABLE IF EXISTS `redcap_notification`; +DROP TABLE IF EXISTS `redcap_dictionary`; DROP TABLE IF EXISTS `candidate_consent_rel`; DROP TABLE IF EXISTS `consent`; diff --git a/SQL/New_patches/2025-07-10_REDCap_front_end_permission.sql b/SQL/New_patches/2025-07-10_REDCap_front_end_permission.sql new file mode 100644 index 00000000000..1d4534695b5 --- /dev/null +++ b/SQL/New_patches/2025-07-10_REDCap_front_end_permission.sql @@ -0,0 +1,38 @@ +-- adds the redcap_ui_view permission +INSERT INTO permissions (code, description, moduleID, categoryID) + VALUES ( + 'redcap_ui_view', + 'REDCap GUI - View', + (SELECT ID FROM modules WHERE Name ='redcap'), + (SELECT ID FROM permissions_category WHERE Description = 'Permission') + ); + +INSERT INTO perm_perm_action_rel (permID, actionID) + VALUES ( + (SELECT permID FROM permissions WHERE code = 'redcap_ui_view'), + (SELECT ID FROM permisssions_action WHERE name = 'View') + ); + +-- adds the redcap dictionary table +CREATE TABLE `redcap_dictionary` ( + ID int(10) unsigned NOT NULL auto_increment, + InstrumentName varchar(255), + FieldName varchar(255) NOT NULL, + FieldRequired boolean NOT NULL, + FieldType varchar(20) NOT NULL, + FieldLabel text NOT NULL, + SectionHeader text, + Choices text, + FieldNote text, + TextValidationType varchar(50), + TextValidationMin varchar(50), + TextValidationMax varchar(50), + Identifier text, + BranchingLogic text, + CustomAlignment varchar(10), + QuestionNumber text, + MatrixGroupName varchar(100), + MatrixRanking varchar(50), + FieldAnnotation text, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='All REDCap instrument fields and variables'; \ No newline at end of file diff --git a/modules/redcap/jsx/redcapIndex.js b/modules/redcap/jsx/redcapIndex.js new file mode 100644 index 00000000000..bc96b571d2b --- /dev/null +++ b/modules/redcap/jsx/redcapIndex.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import {TabPane, Tabs} from 'jsx/Tabs'; +import NotificationViewer from './tabs/notificationViewer'; +import DictionaryViewer from './tabs/dictionaryViewer'; +import IssuesViewer from './tabs/issuesViewer'; +import {createRoot} from 'react-dom/client'; + + +/** + * REDCap index view + * + * @param {array} props + * @return {JSX} + */ +export default function RedcapIndex(props) { + // list of tabs + const tabList = [ + {id: 'notificationTable', label: 'Notifications'}, + {id: 'dictionaryTable', label: 'Data Dictionary'}, + {id: 'issuesTable', label: 'Issues'}, + ]; + + return ( + <> + + + {/* notifications */} + + + + + {/* dictionary */} + + + + + {/* issues */} + + + + + + + ); +} + +RedcapIndex.propTypes = { + baseURL: PropTypes.string.isRequired, + moduleURL: PropTypes.string.isRequired, + hasPermission: PropTypes.func.isRequired, +}; + +/** + * Render REDCap main page on page load + */ +document.addEventListener('DOMContentLoaded', function() { + createRoot( + document.getElementById('lorisworkspace') + ).render( + + ); +}); diff --git a/modules/redcap/jsx/tabs/dictionaryViewer.js b/modules/redcap/jsx/tabs/dictionaryViewer.js new file mode 100644 index 00000000000..6192c61619a --- /dev/null +++ b/modules/redcap/jsx/tabs/dictionaryViewer.js @@ -0,0 +1,252 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import Loader from 'jsx/Loader'; +import FilterableDataTable from 'jsx/FilterableDataTable'; + +/** + * Dictionary viewer component + */ +class DictionaryViewer extends Component { + /** + * Constructor of component + * + * @param {object} props - the component properties. + */ + constructor(props) { + super(props); + + this.state = { + baseURL: props.baseURL, + data: {}, + error: false, + isLoaded: false, + }; + + this.fetchData = this.fetchData.bind(this); + this.formatColumn = this.formatColumn.bind(this); + } + + /** + * Called by React when the component has been rendered on the page. + */ + componentDidMount() { + this.fetchData(); + } + + /** + * Retrieve data for the form and save it in state. + */ + fetchData() { + fetch(`${this.state.baseURL}/dictionary`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + } + ).then((resp) => { + if (!resp.ok) { + this.setState({error: true}); + console.error(resp.status + ': ' + resp.statusText); + return; + } + + resp.json() + .then((data) => { + this.setState({ + data, + isLoaded: true, + }); + }); + }).catch((error) => { + this.setState({error: true}); + console.error(error); + }); + } + + /** + * Modify behaviour of specified column cells in the Data Table component + * + * @param {string} column - column name + * @param {string} cell - cell content + * @param {array} rowData - array of cell contents for a specific row + * @param {array} rowHeaders - array of table headers (column names) + * @return {*} a formatted table cell for a given column + */ + formatColumn(column, cell, rowData, rowHeaders) { + let result = ({cell}); + + // background color + let bgGreen = {textAlign: 'center', backgroundColor: '#EDF7E5'}; + let bgYellow = {textAlign: 'center', backgroundColor: '#FFF696'}; + + switch (column) { + case 'Required': + // yes/no answers + result = (cell === '1') + ? (✅) + : (🟨); + break; + default: + result = ({cell}); + break; + } + return result; + } + + /** + * @return {JSX} the incomplete form to render. + */ + render() { + // Waiting for async data to load. + if (!this.state.isLoaded) { + return ; + } + + // The fields configured for display/hide. + let fields = [ + { + label: 'Instrument', + show: true, + type: 'text', + filter: { + name: 'Instrument', + type: 'text', + }, + }, + { + label: 'Field', + show: true, + type: 'text', + filter: { + name: 'Field', + type: 'text', + }, + }, + { + label: 'Required', + show: true, + type: 'text', + }, + { + label: 'Type', + show: true, + type: 'text', + filter: { + name: 'Type', + type: 'text', + }, + }, + { + label: 'Label', + show: true, + type: 'text', + filter: { + name: 'Label', + type: 'text', + }, + }, + { + label: 'Section Header', + show: false, + type: 'text', + filter: { + name: 'Section Header', + type: 'text', + }, + }, + { + label: 'Choices', + show: true, + type: 'text', + filter: { + name: 'Choices', + type: 'text', + }, + }, + { + label: 'Note', + show: true, + type: 'text', + filter: { + name: 'Project ID', + type: 'text', + }, + }, + { + label: 'Validation Type', + show: true, + type: 'text', + }, + { + label: 'Validation Min', + show: true, + type: 'text', + }, + { + label: 'Validation Max', + show: true, + type: 'text', + }, + { + label: 'Identifier', + show: true, + type: 'text', + }, + { + label: 'Branching Logic', + show: true, + type: 'text', + }, + { + label: 'Custom Alignment', + show: true, + type: 'text', + }, + { + label: 'Question Number', + show: true, + type: 'text', + }, + { + label: 'Matrix Group', + show: true, + type: 'text', + }, + { + label: 'Matrix Ranking', + show: true, + type: 'text', + }, + { + label: 'Annotation', + show: true, + type: 'text', + }, + ]; + + return ( +
+ {this.state.data == null + ? (
+ Error: no Data Dictionary entry found. +
) + : + } +
+ ); + } +} + +DictionaryViewer.propTypes = { + data: PropTypes.object, + baseURL: PropTypes.string.isRequired, +}; + +export default DictionaryViewer; diff --git a/modules/redcap/jsx/tabs/issuesViewer.js b/modules/redcap/jsx/tabs/issuesViewer.js new file mode 100644 index 00000000000..d4272fcf32b --- /dev/null +++ b/modules/redcap/jsx/tabs/issuesViewer.js @@ -0,0 +1,208 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import Loader from 'jsx/Loader'; +import FilterableDataTable from 'jsx/FilterableDataTable'; + +/** + * Issues viewer component + */ +class IssuesViewer extends Component { + /** + * Constructor of component + * + * @param {object} props - the component properties. + */ + constructor(props) { + super(props); + + this.state = { + baseURL: props.baseURL, + moduleURL: props.moduleURL, + data: {}, + error: false, + isLoaded: false, + }; + + this.fetchData = this.fetchData.bind(this); + this.formatColumn = this.formatColumn.bind(this); + } + + /** + * Called by React when the component has been rendered on the page. + */ + componentDidMount() { + this.fetchData(); + } + + /** + * Retrieve data and save it in state. + */ + fetchData() { + fetch(`${this.state.moduleURL}/issues`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + } + ).then((resp) => { + if (!resp.ok) { + this.setState({error: true}); + console.error(resp.status + ': ' + resp.statusText); + return; + } + + resp.json() + .then((data) => { + this.setState({ + data, + isLoaded: true, + }); + }); + }).catch((error) => { + this.setState({error: true}); + console.error(error); + }); + } + + /** + * Modify behaviour of specified column cells in the Data Table component + * + * @param {string} column - column name + * @param {string} cell - cell content + * @param {array} rowData - array of cell contents for a specific row + * @param {array} rowHeaders - array of table headers (column names) + * @return {*} a formatted table cell for a given column + */ + formatColumn(column, cell, rowData, rowHeaders) { + let result = ({cell}); + + // background color + let bgGreen = {textAlign: 'center', backgroundColor: '#EDF7E5'}; + let bgRed = {textAlign: 'center', backgroundColor: '#F7D6E0'}; + let bgYellow = {textAlign: 'center', backgroundColor: '#FFF696'}; + + switch (column) { + case 'Issue ID': + case 'Title': + // console.log(rowData); + let bvlLink = this.props.baseURL + + '/issue_tracker/issue/' + + rowData['Issue ID']; + result = ({rowData[column]}); + break; + case 'Status': + switch (cell) { + case 'closed': + case 'resolved': + result = ({cell}); + break; + case 'new': + case 'assigned': + case 'acknowledged': + case 'feedback': + result = ({cell}); + break; + default: + result = ({cell}); + break; + } + break; + case 'Description': + let a = cell.replace(/"/g, '"'); + result = ({a}); + break; + default: + result = ({cell}); + break; + } + return result; + } + + /** + * @return {JSX} the incomplete form to render. + */ + render() { + // Waiting for async data to load. + if (!this.state.isLoaded) { + return ; + } + + // The fields configured for display/hide. + let fields = [ + { + label: 'Issue ID', + show: false, + type: 'text', + }, + { + label: 'Title', + show: true, + type: 'text', + filter: { + name: 'Title', + type: 'text', + }, + }, + { + label: 'Status', + show: true, + type: 'text', + filter: { + name: 'Status', + type: 'text', + }, + }, + { + label: 'Date Created', + show: true, + type: 'text', + filter: { + name: 'Date Created', + type: 'text', + }, + }, + { + label: 'Description', + show: true, + type: 'text', + filter: { + name: 'Description', + type: 'text', + }, + }, + ]; + + return ( +
+ {this.state.data == null + ? (
+ Error: no issues found. +
) + : <> +
+
+ Note: Only the last 10.000 issues will be + displayed. If more are required, please check the issue tracker. +
+ + + } +
+ ); + } +} + +IssuesViewer.propTypes = { + data: PropTypes.object, + baseURL: PropTypes.string.isRequired, + moduleURL: PropTypes.string.isRequired, +}; + +export default IssuesViewer; diff --git a/modules/redcap/jsx/tabs/notificationViewer.js b/modules/redcap/jsx/tabs/notificationViewer.js new file mode 100644 index 00000000000..42c5fd7d9c4 --- /dev/null +++ b/modules/redcap/jsx/tabs/notificationViewer.js @@ -0,0 +1,194 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import Loader from 'jsx/Loader'; +import FilterableDataTable from 'jsx/FilterableDataTable'; + + +class NotificationViewer extends Component { + /** + * Constructor of component + * + * @param {object} props - the component properties. + */ + constructor(props) { + super(props); + + this.state = { + baseURL: props.baseURL, + data: {}, + error: false, + isLoaded: false, + }; + + this.fetchData = this.fetchData.bind(this); + this.formatColumn = this.formatColumn.bind(this); + } + + /** + * Called by React when the component has been rendered on the page. + */ + componentDidMount() { + this.fetchData(); + } + + /** + * Retrieve data for the form and save it in state. + */ + fetchData() { + fetch(`${this.state.baseURL}/?format=json`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + }).then((resp) => { + if (!resp.ok) { + this.setState({error: true}); + console.error(resp.status + ': ' + resp.statusText); + return; + } + + resp.json() + .then((data) => { + this.setState({ + data, + isLoaded: true, + }); + }); + }).catch((error) => { + this.setState({error: true}); + console.error(error); + }); + } + + /** + * Modify behaviour of specified column cells in the Data Table component + * + * @param {string} column - column name + * @param {string} cell - cell content + * @param {object} row - row content indexed by column + * + * @return {*} a formatted table cell for a given column + */ + formatColumn(column, cell, row) { + let result = ({cell}); + + // background color + let bgGreen = {textAlign: 'center', backgroundColor: '#EDF7E5'}; + let bgRed = {textAlign: 'center', backgroundColor: '#F7D6E0'}; + + switch (column) { + case 'Complete': + // yes/no answers + result = (cell === '2') + ? (✅) + : (❌); + break; + default: + result = ({cell}); + break; + } + return result; + } + + /** + * @return {JSX} the incomplete form to render. + */ + render() { + // Waiting for async data to load. + if (!this.state.isLoaded) { + return ; + } + + // The fields configured for display/hide. + /** + * Fields. + */ + const fields = [ + { + label: 'Notification ID', + show: false, + type: 'text', + }, + { + label: 'Complete', + show: true, + type: 'text', + }, + { + label: 'Record ID', + show: true, + type: 'text', + filter: { + name: 'Record ID', + type: 'text', + }, + }, + { + label: 'Event Name', + show: true, + type: 'text', + filter: { + name: 'Event Name', + type: 'text', + }, + }, + { + label: 'Instrument', + show: true, + type: 'text', + filter: { + name: 'Instrument', + type: 'text', + }, + }, + { + label: 'REDCap URL', + show: true, + type: 'text', + }, + { + label: 'Project ID', + show: true, + type: 'text', + filter: { + name: 'Project ID', + type: 'text', + }, + }, + { + label: 'Received', + show: true, + type: 'date', + }, + { + label: 'Handled', + show: true, + type: 'date', + }, + ]; + + return ( + <> +
+
+ Note: Only the last 10.000 notifications will be + displayed. If more are required, please contact the administrator. +
+ + + ); + } +} + +NotificationViewer.propTypes = { + data: PropTypes.object, + baseURL: PropTypes.string.isRequired, +}; + +export default NotificationViewer; diff --git a/modules/redcap/php/client/models/redcapdictionaryrecord.class.inc b/modules/redcap/php/client/models/redcapdictionaryrecord.class.inc index c550db9b54f..d621dfc4a6f 100644 --- a/modules/redcap/php/client/models/redcapdictionaryrecord.class.inc +++ b/modules/redcap/php/client/models/redcapdictionaryrecord.class.inc @@ -230,6 +230,54 @@ class RedcapDictionaryRecord ]; } + /** + * Used to insert the diciotnary entry in the db redcap_dictionary. + * + * @return array{ + * branching_logic: string|null, + * custom_alignment: string|null, + * field_annotation: string|null, + * field_label: string, + * field_name: string, + * field_note: string|null, + * field_type: string, + * form_name: string, + * identifier: string|null, + * matrix_group_name: string|null, + * matrix_ranking: string|null, + * question_number: string|null, + * required_field: bool, + * section_header: string|null, + * select_choices_or_calculations: string|null, + * text_validation_max: string|null, + * text_validation_min: string|null, + * text_validation_type_or_show_slider_number: string|null + * } + */ + public function toDatabaseArray(): array + { + return [ + 'FieldName' => $this->field_name, + 'FormName' => $this->form_name, + 'SectionHeader' => $this->section_header, + 'FieldType' => $this->field_type, + 'FieldLabel' => $this->field_label, + 'Choices' => $this->select_choices, + 'FieldNote' => $this->field_note, + 'TextValidationType' => $this->text_validation_type, + 'TextValidationMin' => $this->text_validation_min, + 'TextValidationMax' => $this->text_validation_max, + 'Identifier' => $this->identifier, + 'BranchingLogic' => $this->branching_logic, + 'FieldRequired' => $this->required_field, + 'CustomAlignment' => $this->custom_alignment, + 'QuestionNumber' => $this->question_number, + 'MatrixGroupName' => $this->matrix_group_name, + 'MatrixRanking' => $this->matrix_ranking, + 'FieldAnnotation' => $this->field_annotation, + ]; + } + /** * Get the LINST version of that dicitonary record * diff --git a/modules/redcap/php/dictionary.class.inc b/modules/redcap/php/dictionary.class.inc new file mode 100644 index 00000000000..1767bbe94f2 --- /dev/null +++ b/modules/redcap/php/dictionary.class.inc @@ -0,0 +1,119 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\redcap; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * This class implements the /dictionary endpoint for redcap module. + * + * PHP Version 8 + * + * @category REDCap + * @package Main + * @author Regis Ongaro-Carcy + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class Dictionary extends \NDB_Page +{ + /** + * Check if user should be allowed to see this page. + * + * @param \User $user The user whose access is being checked + * + * @return bool true if the user is permitted to see violated scans + */ + function _hasAccess(\User $user) : bool + { + return $user->hasAnyPermission( + [ + 'redcap_ui_view', + ] + ); + } + + /** + * Return an array of valid HTTP methods for this endpoint + * + * @return string[] Valid versions + */ + protected function allowedMethods(): array + { + return [ + 'GET', + ]; + } + + /** + * This function will return a json response. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + return match ($request->getMethod()) { + 'GET' => $this->_handleGET(), + default => new \LORIS\Http\Response\JSON\MethodNotAllowed( + $this->allowedMethods() + ), + }; + } + + /** + * Handle an incoming HTTP GET request. + * + * @return ResponseInterface + */ + private function _handleGET() : ResponseInterface + { + $db = $this->loris->getDatabaseConnection(); + $dictionary = $db->pselect( + "SELECT + InstrumentName, + FieldName, + FieldRequired, + FieldType, + FieldLabel, + SectionHeader, + Choices, + FieldNote, + TextValidationType, + TextValidationMin, + TextValidationMax, + Identifier, + BranchingLogic, + CustomAlignment, + QuestionNumber, + MatrixGroupName, + MatrixRanking, + FieldAnnotation + FROM redcap_dictionary", + [] + ); + + if ($dictionary->count() === 0) { + return new \LORIS\Http\Response\JSON\OK([]); + } + + return new \LORIS\Http\Response\JSON\OK( + array_map( + fn($v) => array_values($v), + iterator_to_array($dictionary->getIterator()) + ) + ); + } +} diff --git a/modules/redcap/php/issues.class.inc b/modules/redcap/php/issues.class.inc new file mode 100644 index 00000000000..91adb3c17a9 --- /dev/null +++ b/modules/redcap/php/issues.class.inc @@ -0,0 +1,120 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\redcap; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * This class implements the /issues endpoint for redcap module. + * + * PHP Version 8 + * + * @category REDCap + * @package Main + * @author Regis Ongaro-Carcy + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class Issues extends \NDB_Page +{ + /** + * Check if user should be allowed to see this page. + * + * @param \User $user The user whose access is being checked + * + * @return bool true if the user is permitted to see violated scans + */ + function _hasAccess(\User $user) : bool + { + return $user->hasAnyPermission( + [ + 'redcap_ui_view', + ] + ); + } + + /** + * Return an array of valid HTTP methods for this endpoint + * + * @return string[] Valid versions + */ + protected function allowedMethods(): array + { + return [ + 'GET', + ]; + } + + /** + * This function will return a json response. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + // Ensure GET or POST request. + switch ($request->getMethod()) { + case 'GET': + return $this->_handleGET(); + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed( + $this->allowedMethods() + ); + } + } + + /** + * Handle an incoming HTTP GET request. + * + * @return ResponseInterface + */ + private function _handleGET(): ResponseInterface + { + $db = $this->loris->getDatabaseConnection(); + $issues = $db->pselect( + "SELECT + i.issueID, + i.title, + i.status, + i.dateCreated, + ( + SELECT ic.issueComment + FROM issues_comments ic + WHERE ic.issueID = i.issueID + ORDER BY ic.dateAdded DESC + LIMIT 1 + ) as description + FROM issues i + JOIN modules m ON (m.ID = i.module) + WHERE m.Name = 'redcap' + ORDER BY i.dateCreated DESC + LIMIT 10000 + ", + [] + ); + + if ($issues->count() === 0) { + return new \LORIS\Http\Response\JSON\OK([]); + } + + return new \LORIS\Http\Response\JSON\OK( + array_map( + fn($v) => array_values($v), + iterator_to_array($issues->getIterator()) + ) + ); + } +} diff --git a/modules/redcap/php/module.class.inc b/modules/redcap/php/module.class.inc index 0f325c1f856..5dd0d9feaec 100644 --- a/modules/redcap/php/module.class.inc +++ b/modules/redcap/php/module.class.inc @@ -52,6 +52,16 @@ class Module extends \Module return "REDCap"; } + /** + * {@inheritDoc} + * + * @return ?\LORIS\GUI\MenuCategory The menu category for this module + */ + public function getMenuCategory() : ?\LORIS\GUI\MenuCategory + { + return \LORIS\GUI\MenuCategory::singleton("Tools"); + } + /** * Perform routing for this module's endpoints. * diff --git a/modules/redcap/php/redcap.class.inc b/modules/redcap/php/redcap.class.inc new file mode 100644 index 00000000000..075c8505318 --- /dev/null +++ b/modules/redcap/php/redcap.class.inc @@ -0,0 +1,99 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\redcap; + +/** + * Implement the menu filter for searching through redcap data + * + * @category REDCap + * @package Main + * @author Regis Ongaro-Carcy + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class REDCap extends \DataFrameworkMenu +{ + /** + * Check if user should be allowed to see this page. + * + * @param \User $user The user whose access is being checked + * + * @return bool true if the user is permitted to see violated scans + */ + function _hasAccess(\User $user) : bool + { + return $user->hasAnyPermission( + [ + 'redcap_ui_view', + ] + ); + } + + /** + * Tells the base class that this page's provisioner can support the + * HasAnyPermissionOrUserSiteMatch filter. + * + * @return ?array of site permissions or null + */ + public function allSitePermissionNames() : ?array + { + return null; + } + + /** + * Set up the variables required by NDB_Menu_Filter class for + * constructing a query + * + * @return array list of sites + */ + public function getFieldOptions(): array + { + return []; + } + + /** + * Tells the base class that this page's provisioner can support + * the UserProjectMatch filter. + * + * @return bool always true + */ + public function useProjectFilter(): bool + { + return false; + } + + /** + * Gets the data source for this menu filter. + * + * @return \LORIS\Data\Provisioner + */ + public function getBaseDataProvisioner() : \LORIS\Data\Provisioner + { + return new REDCapProvisioner($this->loris); + } + + /** + * Overrides base getJSDependencies() to add support for a React file. + * + * @return array of extra JS files that this page depends on + */ + public function getJSDependencies(): array + { + $factory = \NDB_Factory::singleton(); + $baseurl = $factory->settings()->getBaseURL(); + return array_merge( + parent::getJSDependencies(), + [$baseurl . "/redcap/js/redcapIndex.js"] + ); + } +} diff --git a/modules/redcap/php/redcapprovisioner.class.inc b/modules/redcap/php/redcapprovisioner.class.inc new file mode 100644 index 00000000000..c0a1b3bba99 --- /dev/null +++ b/modules/redcap/php/redcapprovisioner.class.inc @@ -0,0 +1,72 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\redcap; + +/** + * This class implements a data provisioner to get all possible rows + * for the REDCap menu page. + * + * PHP Version 8 + * + * @category REDCap + * @package Main + * @author Regis Ongaro-Carcy + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class REDCapProvisioner extends \LORIS\Data\Provisioners\DBRowProvisioner +{ + /** + * Create a REDCapProvisioner. + * + * @param \LORIS\LorisInstance $loris the loris instance + */ + public function __construct(\LORIS\LorisInstance $loris) + { + // note: that this needs to be in the same order as the headers array + parent::__construct( + $loris, + "SELECT + id as notificationID, + complete, + record as recordID, + redcap_event_name as eventName, + instrument, + redcap_url as redcapURL, + project_id as projectID, + received_dt, + handled_dt + FROM redcap_notification + ORDER BY received_dt DESC + LIMIT 10000 + ", + [] + ); + } + + /** + * Returns an instance of an REDCap notification object for a given + * table row. + * + * @param array $row The database row from the LORIS Database class. + * + * @return \LORIS\Data\DataInstance An instance representing this row. + */ + public function getInstance($row) : \LORIS\Data\DataInstance + { + return new RedcapRow($row); + } +} diff --git a/modules/redcap/php/redcapqueries.class.inc b/modules/redcap/php/redcapqueries.class.inc index 9afb18b4ed7..2567fa2004e 100644 --- a/modules/redcap/php/redcapqueries.class.inc +++ b/modules/redcap/php/redcapqueries.class.inc @@ -13,6 +13,7 @@ namespace LORIS\redcap; use \LORIS\LorisInstance; +use LORIS\redcap\client\models\RedcapDictionaryRecord; use \LORIS\StudyEntities\Candidate\CandID; /** @@ -351,4 +352,115 @@ class RedcapQueries { $this->_db->run("UNLOCK TABLES"); } + + /** + * Tells if the instrument name is registered in the REDCap dictionary. + * + * @param string $instrument_name the instrument name to search + * + * @return bool true if the instrument name exists, else false. + */ + public function instrumentExistsInREDCapDictionary(string $instrument_name): bool + { + $dictionary_entry = $this->_db->pselectOne( + "SELECT DISTINCT FormName + FROM redcap_dictionary + WHERE FormName = :fn + ", + ["fn" => $instrument_name] + ); + return $dictionary_entry !== null; + } + + /** + * Get a specific REDCap Dictionary entry. + * + * @param string $form_name the searched form name. + * @param string $field_name the searched field name. + * + * @return client\models\RedcapDictionaryRecord|null + */ + public function getDictionaryEntry( + string $form_name, + string $field_name + ): ?client\models\RedcapDictionaryRecord { + $dictionary_entry = $this->_db->pselectRow( + "SELECT + FieldName, + FormName, + SectionHeader, + FieldType, + FieldLabel, + Choices, + FieldNote, + TextValidationType, + TextValidationMin, + TextValidationMax, + Identifier, + BranchingLogic, + FieldRequired, + CustomAlignment, + QuestionNumber, + MatrixGroupName, + MatrixRanking, + FieldAnnotation + FROM redcap_dictionary + WHERE FormName = :form_name + AND FieldName = :field_name + ", + [ + "form_name" => $form_name, + "field_name" => $field_name + ] + ); + + if ($dictionary_entry === null) { + return null; + } + + // dictionary props + $clean_props = array_combine( + RedcapDictionaryRecord::getHeaders(), + array_values($dictionary_entry) + ); + + // build the object based on props + return new RedcapDictionaryRecord($clean_props, false); + } + + /** + * Insert a REDCap dictionary entry. + * + * @param RedcapDictionaryRecord $dictionary_entry the dicitonary entry to insert + * + * @return void + */ + public function insertREDCapDictionaryEntry( + RedcapDictionaryRecord $dictionary_entry + ): void { + $this->_db->insert( + 'redcap_dictionary', + $dictionary_entry->toDatabaseArray() + ); + } + + /** + * Update a REDCap dictionary entry. + * + * @param RedcapDictionaryRecord $dictionary_entry the dicitonary entry to insert + * + * @return void + */ + public function updateREDCapDictionaryEntry( + RedcapDictionaryRecord $dictionary_entry + ): void { + $this->_db->update( + 'redcap_dictionary', + $dictionary_entry->toDatabaseArray(), + [ + "FormName" => $dictionary_entry->form_name, + "FieldName" => $dictionary_entry->field_name + ] + ); + } } diff --git a/modules/redcap/php/redcaprow.class.inc b/modules/redcap/php/redcaprow.class.inc new file mode 100644 index 00000000000..50e2bd36361 --- /dev/null +++ b/modules/redcap/php/redcaprow.class.inc @@ -0,0 +1,53 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +namespace LORIS\redcap; + +/** + * An REDCap row represents a row in the REDCap menu table. + * + * @category REDCap + * @package Main + * @author Regis Ongaro-Carcy + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class RedcapRow implements \LORIS\Data\DataInstance +{ + protected $DBRow; + + protected $CenterIDs; + + /** + * Create a new redcap row + * + * @param array $row The row (in the same format as \Database::pselectRow + * returns.) + */ + public function __construct(array $row) + { + $this->DBRow = $row; + } + + /** + * Implements \LORIS\Data\DataInstance interface for this row. + * + * @return array which can be serialized by json_encode() + */ + public function jsonSerialize() : array + { + return $this->DBRow; + } +} diff --git a/raisinbread/RB_files/RB_modules.sql b/raisinbread/RB_files/RB_modules.sql index a85b2bd0006..734c7fe88a7 100644 --- a/raisinbread/RB_files/RB_modules.sql +++ b/raisinbread/RB_files/RB_modules.sql @@ -52,5 +52,6 @@ INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (49,'dataquery','Y'); INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (50,'oidc','N'); INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (51,'policy_tracker','Y'); INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (52,'biobank','Y'); +INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (53,'redcap','N'); UNLOCK TABLES; SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_permissions.sql b/raisinbread/RB_files/RB_permissions.sql index fd4ad00a305..2e560c714d5 100644 --- a/raisinbread/RB_files/RB_permissions.sql +++ b/raisinbread/RB_files/RB_permissions.sql @@ -81,5 +81,6 @@ INSERT INTO `permissions` (`permID`, `code`, `description`, `moduleID`, `categor INSERT INTO `permissions` (`permID`, `code`, `description`, `moduleID`, `categoryID`) VALUES (87,'biobank_container_view','View Containers',51,2); INSERT INTO `permissions` (`permID`, `code`, `description`, `moduleID`, `categoryID`) VALUES (88,'biobank_pool_view','View Pools',51,2); INSERT INTO `permissions` (`permID`, `code`, `description`, `moduleID`, `categoryID`) VALUES (89,'dqt_view','Cross-Modality Data (legacy)',44,2); +INSERT INTO `permissions` (`permID`, `code`, `description`, `moduleID`, `categoryID`) VALUES (90,'redcap_ui_view','REDCap GUI - View',53,2); UNLOCK TABLES; SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_user_perm_rel.sql b/raisinbread/RB_files/RB_user_perm_rel.sql index b91066a1c14..7191d635254 100644 --- a/raisinbread/RB_files/RB_user_perm_rel.sql +++ b/raisinbread/RB_files/RB_user_perm_rel.sql @@ -80,5 +80,6 @@ INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,83); INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,84); INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,85); INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,86); +INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,90); UNLOCK TABLES; SET FOREIGN_KEY_CHECKS=1; diff --git a/tools/redcap2linst.php b/tools/redcap2linst.php index 3571489f38b..c30f8458125 100644 --- a/tools/redcap2linst.php +++ b/tools/redcap2linst.php @@ -16,18 +16,25 @@ * @link https://www.github.com/aces/Loris/ */ -require_once __DIR__ . "/generic_includes.php"; +require_once __DIR__ . "/../tools/generic_includes.php"; + + // load redcap module +try { + $lorisInstance->getModule('redcap')->registerAutoloader(); +} catch (\LorisNoSuchModuleException $th) { + error_log("[error] no 'redcap' module found."); + exit(1); +} // ini_set('display_errors', 1); // ini_set('display_startup_errors', 1); // error_reporting(E_ALL); -// load redcap module to use the client -$lorisInstance->getModule('redcap')->registerAutoloader(); - use LORIS\redcap\client\RedcapHttpClient; use LORIS\redcap\client\models\RedcapDictionaryRecord; +use LORIS\redcap\client\models\RedcapInstrument; use LORIS\redcap\config\RedcapConfigParser; +use LORIS\redcap\Queries; // options $opts = getopt( @@ -70,10 +77,12 @@ // create LINST lines by instrument fwrite(STDOUT, "\n-- Parsing records...\n"); $instruments = []; +$dictionary = []; foreach ($dict as $dict_record) { $linst = $dict_record->toLINST(); if (!empty($linst)) { $instruments[$dict_record->form_name][] = $linst; + $dictionary[$dict_record->form_name][] = $dict_record; } } @@ -83,23 +92,128 @@ // back-end name/title instrument mapping $redcap_intruments_map = ($options['redcapConnection'])->getInstruments(true); -fwrite(STDOUT, "\n-- Writing LINST/META files.\n\n"); - // write instrument -foreach ($instruments as $instrument_name => $instrument) { +fwrite(STDOUT, "\n-- Writing LINST/META files.\n\n"); +foreach ($instruments as $instrument_name => $fields) { writeLINSTFile( $options['outputDir'], - $instrument_name, $redcap_intruments_map[$instrument_name], - $instrument + $fields ); } +// update the db redcap_dictionary table per instrument +fwrite(STDOUT, "\n-- Writing entries to database 'redcap_dictionary' table.\n\n"); +$queries = new Queries($lorisInstance); +$nbEntriesCount = [ + 'created' => 0, + 'updated' => 0, + 'untouched' => 0 +]; +foreach ($dictionary as $instrument_name => $dictionary_entry) { + $nbEntries = updateREDCapDictionaryInstrumentEntries( + $queries, + $instrument_name, + $dictionary_entry + ); + + // update count + $nbEntriesCount['created'] += $nbEntries['created']; + $nbEntriesCount['updated'] += $nbEntries['updated']; + $nbEntriesCount['untouched'] += $nbEntries['untouched']; +} + +// db log +fwrite(STDOUT, "\n -> 'redcap_dictionary' table changes:"); +fwrite(STDOUT, "\n - fields created: {$nbEntriesCount['created']}"); +fwrite(STDOUT, "\n - fields updated: {$nbEntriesCount['updated']}"); +fwrite(STDOUT, "\n - fields untouched: {$nbEntriesCount['untouched']}\n"); + fwrite(STDOUT, "\n-- end\n"); // ------------ Functions +/** + * Updates the redcap_diciotnary table in db. + * + * @param LORIS\redcap\Queries $queries queries object + * @param string $instrument_name the instrument name to update + * @param RedcapDictionaryRecord[] $redcap_dictionary the redcap dictionary + * + * @return int[] an array of #created and #updated entries for this instrument + */ +function updateREDCapDictionaryInstrumentEntries( + Queries $queries, + string $instrument_name, + array $redcap_dictionary +): array { + // updating or creating new entry? + $instrumentExists = $queries->instrumentExistsInREDCapDictionary( + $instrument_name + ); + $updateMsg = $instrumentExists ? "updating" : "creating"; + fwrite(STDOUT, " -> {$updateMsg} '{$instrument_name}'\n"); + + // count + $nbEntriesCount = [ + 'created' => 0, + 'updated' => 0, + 'untouched' => 0 + ]; + + // closure for escaped html + $cleanHTML = fn($v) => is_string($v) + ? htmlspecialchars( + $v, + ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, + 'UTF-8', + false + ) + : $v; + + // go through entries for this instrument + foreach ($redcap_dictionary as $redcap_dictionary_entry) { + // get the db correspoding field name + $db_dictionary_field = $queries->getDictionaryEntry( + $redcap_dictionary_entry->form_name, + $redcap_dictionary_entry->field_name + ); + + // new entry => insert + if ($db_dictionary_field === null) { + $queries->insertREDCapDictionaryEntry($redcap_dictionary_entry); + $nbEntriesCount['created'] += 1; + continue; + } + + // escape HTML from this entry to compare + // same method use in Database class -> update + $escaped_props = array_map( + $cleanHTML, + $redcap_dictionary_entry->toArray() + ); + $redcap_escaped_entry = new RedcapDictionaryRecord( + array_combine( + RedcapDictionaryRecord::getHeaders(), + array_values($escaped_props) + ) + ); + + // same entry, nothing to do + if ($db_dictionary_field == $redcap_escaped_entry) { + $nbEntriesCount['untouched'] += 1; + continue; + } + + // different entry => update + $queries->updateREDCapDictionaryEntry($redcap_dictionary_entry); + $nbEntriesCount['updated'] += 1; + } + + return $nbEntriesCount; +} + /** * Get the dictionary from a CSV file. * @@ -152,19 +266,22 @@ function getDictionaryCSVStream( // metadata $metadata = $row; + // combine headers/data + $payload = array_combine($headers, $metadata); + // do not trim if (!$trim_name) { - $dd = new RedcapDictionaryRecord(zip($headers, $metadata)); + $dd = new RedcapDictionaryRecord($payload); $mapped++; } else { // try to trim form name try { - $dd = new RedcapDictionaryRecord(zip($headers, $metadata), true); + $dd = new RedcapDictionaryRecord($payload, true); $mapped++; } catch (\LorisException $le) { // print error but continue with non trimmed fprintf(STDERR, $le->getMessage()); - $dd = new RedcapDictionaryRecord(zip($headers, $metadata)); + $dd = new RedcapDictionaryRecord($payload); $badMap++; } } @@ -182,57 +299,33 @@ function getDictionaryCSVStream( return $dictionary; } -/** - * Zips values from the first array as keys with the values from the second - * array as values. Such as: - * ``` - * # input - * $a1 = [0 => 'a', 1 => 'b', 2 => 'c'] - * $a2 = [0 => 'yes', 1 => 'no', 2 => 'maybe'] - * # result - * $zipped = ['a' => 'yes', 'b' => 'no', 'c' => 'maybe'] - * ``` - * Both arrays must have the same length. - * - * @param array $headers the key array - * @param array $metadata the value array. - * - * @return array a zipped array. - */ -function zip(array &$headers, array &$metadata): array -{ - if (count($headers) !== count($metadata)) { - fwrite(STDERR, "Cannot zip headers with metadata.\n"); - } - $zipped = []; - foreach ($headers as $i => $h) { - $zipped[$h] = $metadata[$i]; - } - return $zipped; -} - /** * Write LINST file and its associated META file. * - * @param string $output_dir the output directory - * @param string $instrument_name the instrument name - * @param string $instrument_title the instrument title/label - * @param array $instrument the instrument data + * @param string $output_dir the output directory + * @param RedcapInstrument $instrument the instrument object + * @param array $fields the instrument data * * @return void */ function writeLINSTFile( string $output_dir, - string $instrument_name, - string $instrument_title, - array $instrument + RedcapInstrument $instrument, + array $fields ): void { - fwrite(STDERR, " -> writing '$instrument_name'\n"); + fwrite(STDOUT, " -> writing '{$instrument->name}'\n"); // - $fp = fopen("$output_dir/$instrument_name.linst", "w"); - fwrite($fp, "testname{@}$instrument_name\n"); - fwrite($fp, "table{@}$instrument_name\n"); - fwrite($fp, "title{@}$instrument_title\n"); + $fp = fopen("{$output_dir}/{$instrument->name}.linst", "w"); + + if ($fp === false) { + throw new \LorisException( + "Cannot open path: {$output_dir}/{$instrument->name}.linst" + ); + } + + fwrite($fp, "{-@-}testname{@}{$instrument->name}\n"); + fwrite($fp, "table{@}{$instrument->name}\n"); + fwrite($fp, "title{@}{$instrument->label}\n"); // Standard LORIS metadata fields that the instrument builder adds // and LINST class automatically adds to instruments. @@ -242,7 +335,7 @@ function writeLINSTFile( fwrite($fp, "static{@}Window_Difference{@}Window Difference (+/- Days)\n"); fwrite($fp, "select{@}Examiner{@}Examiner{@}NULL=>''\n"); - foreach ($instrument as $field) { + foreach ($fields as $field) { // avoid 'timestamp_start', changed in 'static' instead of 'text' if (str_contains($field, "{@}timestamp_start{@}")) { // transform timestamp start to static @@ -255,7 +348,6 @@ function writeLINSTFile( } else { // write field line fwrite($fp, "$field\n"); - } } @@ -263,9 +355,16 @@ function writeLINSTFile( fclose($fp); // META file - $fp_meta = fopen("$output_dir/$instrument_name.meta", "w"); - fwrite($fp_meta, "testname{@}$instrument_name\n"); - fwrite($fp_meta, "table{@}$instrument_name\n"); + $fp_meta = fopen("{$output_dir}/{$instrument->name}.meta", "w"); + + if ($fp_meta === false) { + throw new \LorisException( + "Cannot open path: {$output_dir}/{$instrument->name}.meta" + ); + } + + fwrite($fp_meta, "testname{@}{$instrument->name}\n"); + fwrite($fp_meta, "table{@}{$instrument->name}\n"); fwrite($fp_meta, "jsondata{@}true\n"); fwrite($fp_meta, "norules{@}true"); fclose($fp_meta); @@ -281,7 +380,7 @@ function showHelp() global $argv; fprintf( STDERR, - "\nUsage: $argv[0]\n\n" + "\nUsage: {$argv[0]}\n\n" . "Creates LINST files based on an input file" . " or directly from a REDCap connection.\n" . "A valid REDCap connection is required to get the instruments back-end" @@ -311,21 +410,42 @@ function showHelp() * * @return array */ + +/** + * Check options and return a clean version. + * + * @param LORIS\LorisInstance $loris a loris instance + * @param array $options options + * + * @return array{ + * file: string|null, + * inputType: 'api'|'file', + * outputDir: string, + * redcapConnection: RedcapHttpClient, + * redcapInstance: string, + * redcapProject: string, + * trimInstrumentName: bool + * } + */ function checkOptions(\LORIS\LorisInstance $loris, array &$options): array { // ouput dir $output_dir = $options['o'] ?? $options['output-dir'] ?? null; - if (empty($output_dir)) { + if ($output_dir === null || empty($output_dir)) { fprintf(STDERR, "Output directory required.\n"); showHelp(); exit(1); } + if (!is_string($output_dir)) { + fprintf(STDERR, "Output directory '{$output_dir}' must be a path.\n"); + exit(1); + } if (!is_dir($output_dir)) { - fprintf(STDERR, "Output directory '$output_dir' does not exist.\n"); + fprintf(STDERR, "Output directory '{$output_dir}' does not exist.\n"); exit(1); } if (!is_writeable($output_dir)) { - fprintf(STDERR, "Output directory '$output_dir' is not writeable.\n"); + fprintf(STDERR, "Output directory '{$output_dir}' is not writeable.\n"); exit(1); } @@ -353,7 +473,7 @@ function checkOptions(\LORIS\LorisInstance $loris, array &$options): array if (!is_file($input_file)) { fprintf( STDERR, - "Input file '$input_file' does not exist or is not readable.\n" + "Input file '{$input_file}' does not exist or is not readable.\n" ); exit(1); } @@ -363,12 +483,12 @@ function checkOptions(\LORIS\LorisInstance $loris, array &$options): array $redcap_instance = $options['r'] ?? $options['redcap-instance'] ?? null; $redcap_project = $options['p'] ?? $options['redcap-project'] ?? null; - if (empty($redcap_instance)) { + if ($redcap_instance === null || empty($redcap_instance)) { fprintf(STDERR, "REDCap instance name required.\n"); showHelp(); exit(1); } - if (empty($redcap_project)) { + if ($redcap_project === null || empty($redcap_project)) { fprintf(STDERR, "REDCap project ID required.\n"); showHelp(); exit(1); @@ -388,7 +508,7 @@ function checkOptions(\LORIS\LorisInstance $loris, array &$options): array ); // trim instrument name - $trim_name = isset($options['t']) || isset($options['trim-formname']) || false; + $trim_name = isset($options['trim-formname']) || false; // checked and clean options return [ diff --git a/webpack.config.ts b/webpack.config.ts index c4d5bfcb740..f53628b9d4c 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -85,6 +85,12 @@ const lorisModules: Record = { api_docs: ['swagger-ui_custom'], dashboard: ['welcome'], my_preferences: ['mfa'], + redcap: [ + 'redcapIndex', + 'tabs/dictionaryViewer', + 'tabs/notificationViewer', + 'tabs/issuesViewer', + ], }; /* @@ -258,7 +264,7 @@ function addProjectModules( // Copy the record of LORIS modules const allModules: Record = modules; - + // Add project-specific modules and overrides to the record of modules for (const [moduleName, moduleEntryPoints] of Object.entries(projectModules)