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)